Index Patterns page',
- }
- );
- return (
-
- );
-}
diff --git a/vars/workers.groovy b/vars/workers.groovy
index b6ff5b27667dd6..a1d569595ab4b9 100644
--- a/vars/workers.groovy
+++ b/vars/workers.groovy
@@ -9,6 +9,8 @@ def label(size) {
return 'docker && linux && immutable'
case 's-highmem':
return 'docker && tests-s'
+ case 'm-highmem':
+ return 'docker && linux && immutable && gobld/machineType:n1-highmem-8'
case 'l':
return 'docker && tests-l'
case 'xl':
@@ -132,7 +134,7 @@ def ci(Map params, Closure closure) {
// Worker for running the current intake jobs. Just runs a single script after bootstrap.
def intake(jobName, String script) {
return {
- ci(name: jobName, size: 's-highmem', ramDisk: true) {
+ ci(name: jobName, size: 'm-highmem', ramDisk: true) {
withEnv(["JOB=${jobName}"]) {
kibanaPipeline.notifyOnError {
runbld(script, "Execute ${jobName}")
diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts
index 171f8d4b0b1d43..8b6c25e1c3f241 100644
--- a/x-pack/plugins/actions/server/actions_client.test.ts
+++ b/x-pack/plugins/actions/server/actions_client.test.ts
@@ -15,6 +15,8 @@ import { actionsConfigMock } from './actions_config.mock';
import { getActionsConfigurationUtilities } from './actions_config';
import { licenseStateMock } from './lib/license_state.mock';
import { licensingMock } from '../../licensing/server/mocks';
+import { httpServerMock } from '../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../security/server/audit/index.mock';
import {
elasticsearchServiceMock,
@@ -22,17 +24,23 @@ import {
} from '../../../../src/core/server/mocks';
import { actionExecutorMock } from './lib/action_executor.mock';
import uuid from 'uuid';
-import { KibanaRequest } from 'kibana/server';
import { ActionsAuthorization } from './authorization/actions_authorization';
import { actionsAuthorizationMock } from './authorization/actions_authorization.mock';
+jest.mock('../../../../src/core/server/saved_objects/service/lib/utils', () => ({
+ SavedObjectsUtils: {
+ generateId: () => 'mock-saved-object-id',
+ },
+}));
+
const defaultKibanaIndex = '.kibana';
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const scopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
const actionExecutor = actionExecutorMock.create();
const authorization = actionsAuthorizationMock.create();
const executionEnqueuer = jest.fn();
-const request = {} as KibanaRequest;
+const request = httpServerMock.createKibanaRequest();
+const auditLogger = auditServiceMock.create().asScoped(request);
const mockTaskManager = taskManagerMock.createSetup();
@@ -68,6 +76,7 @@ beforeEach(() => {
executionEnqueuer,
request,
authorization: (authorization as unknown) as ActionsAuthorization,
+ auditLogger,
});
});
@@ -142,6 +151,95 @@ describe('create()', () => {
});
});
+ describe('auditLogger', () => {
+ test('logs audit event when creating a connector', async () => {
+ const savedObjectCreateResult = {
+ id: '1',
+ type: 'action',
+ attributes: {
+ name: 'my name',
+ actionTypeId: 'my-action-type',
+ config: {},
+ },
+ references: [],
+ };
+ actionTypeRegistry.register({
+ id: savedObjectCreateResult.attributes.actionTypeId,
+ name: 'My action type',
+ minimumLicenseRequired: 'basic',
+ executor,
+ });
+ unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult);
+
+ await actionsClient.create({
+ action: {
+ ...savedObjectCreateResult.attributes,
+ secrets: {},
+ },
+ });
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_create',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: 'mock-saved-object-id', type: 'action' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to create a connector', async () => {
+ const savedObjectCreateResult = {
+ id: '1',
+ type: 'action',
+ attributes: {
+ name: 'my name',
+ actionTypeId: 'my-action-type',
+ config: {},
+ },
+ references: [],
+ };
+ actionTypeRegistry.register({
+ id: savedObjectCreateResult.attributes.actionTypeId,
+ name: 'My action type',
+ minimumLicenseRequired: 'basic',
+ executor,
+ });
+ unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult);
+
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(
+ async () =>
+ await actionsClient.create({
+ action: {
+ ...savedObjectCreateResult.attributes,
+ secrets: {},
+ },
+ })
+ ).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_create',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: 'mock-saved-object-id',
+ type: 'action',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
+
test('creates an action with all given properties', async () => {
const savedObjectCreateResult = {
id: '1',
@@ -185,6 +283,9 @@ describe('create()', () => {
"name": "my name",
"secrets": Object {},
},
+ Object {
+ "id": "mock-saved-object-id",
+ },
]
`);
});
@@ -289,6 +390,9 @@ describe('create()', () => {
"name": "my name",
"secrets": Object {},
},
+ Object {
+ "id": "mock-saved-object-id",
+ },
]
`);
});
@@ -440,7 +544,7 @@ describe('get()', () => {
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
});
- test('throws when user is not authorised to create the type of action', async () => {
+ test('throws when user is not authorised to get the type of action', async () => {
unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
id: '1',
type: 'type',
@@ -463,7 +567,7 @@ describe('get()', () => {
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
});
- test('throws when user is not authorised to create preconfigured of action', async () => {
+ test('throws when user is not authorised to get preconfigured of action', async () => {
actionsClient = new ActionsClient({
actionTypeRegistry,
unsecuredSavedObjectsClient,
@@ -501,6 +605,61 @@ describe('get()', () => {
});
});
+ describe('auditLogger', () => {
+ test('logs audit event when getting a connector', async () => {
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'type',
+ attributes: {
+ name: 'my name',
+ actionTypeId: 'my-action-type',
+ config: {},
+ },
+ references: [],
+ });
+
+ await actionsClient.get({ id: '1' });
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_get',
+ outcome: 'success',
+ }),
+ kibana: { saved_object: { id: '1', type: 'action' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to get a connector', async () => {
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'type',
+ attributes: {
+ name: 'my name',
+ actionTypeId: 'my-action-type',
+ config: {},
+ },
+ references: [],
+ });
+
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(actionsClient.get({ id: '1' })).rejects.toThrow();
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_get',
+ outcome: 'failure',
+ }),
+ kibana: { saved_object: { id: '1', type: 'action' } },
+ error: { code: 'Error', message: 'Unauthorized' },
+ })
+ );
+ });
+ });
+
test('calls unsecuredSavedObjectsClient with id', async () => {
unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
id: '1',
@@ -632,6 +791,64 @@ describe('getAll()', () => {
});
});
+ describe('auditLogger', () => {
+ test('logs audit event when searching connectors', async () => {
+ unsecuredSavedObjectsClient.find.mockResolvedValueOnce({
+ total: 1,
+ per_page: 10,
+ page: 1,
+ saved_objects: [
+ {
+ id: '1',
+ type: 'type',
+ attributes: {
+ name: 'test',
+ config: {
+ foo: 'bar',
+ },
+ },
+ score: 1,
+ references: [],
+ },
+ ],
+ });
+ scopedClusterClient.callAsInternalUser.mockResolvedValueOnce({
+ aggregations: {
+ '1': { doc_count: 6 },
+ testPreconfigured: { doc_count: 2 },
+ },
+ });
+
+ await actionsClient.getAll();
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_find',
+ outcome: 'success',
+ }),
+ kibana: { saved_object: { id: '1', type: 'action' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to search connectors', async () => {
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(actionsClient.getAll()).rejects.toThrow();
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_find',
+ outcome: 'failure',
+ }),
+ error: { code: 'Error', message: 'Unauthorized' },
+ })
+ );
+ });
+ });
+
test('calls unsecuredSavedObjectsClient with parameters', async () => {
const expectedResult = {
total: 1,
@@ -773,6 +990,62 @@ describe('getBulk()', () => {
});
});
+ describe('auditLogger', () => {
+ test('logs audit event when bulk getting connectors', async () => {
+ unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({
+ saved_objects: [
+ {
+ id: '1',
+ type: 'action',
+ attributes: {
+ actionTypeId: 'test',
+ name: 'test',
+ config: {
+ foo: 'bar',
+ },
+ },
+ references: [],
+ },
+ ],
+ });
+ scopedClusterClient.callAsInternalUser.mockResolvedValueOnce({
+ aggregations: {
+ '1': { doc_count: 6 },
+ testPreconfigured: { doc_count: 2 },
+ },
+ });
+
+ await actionsClient.getBulk(['1']);
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_get',
+ outcome: 'success',
+ }),
+ kibana: { saved_object: { id: '1', type: 'action' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to bulk get connectors', async () => {
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(actionsClient.getBulk(['1'])).rejects.toThrow();
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_get',
+ outcome: 'failure',
+ }),
+ kibana: { saved_object: { id: '1', type: 'action' } },
+ error: { code: 'Error', message: 'Unauthorized' },
+ })
+ );
+ });
+ });
+
test('calls getBulk unsecuredSavedObjectsClient with parameters', async () => {
unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
@@ -864,6 +1137,39 @@ describe('delete()', () => {
});
});
+ describe('auditLogger', () => {
+ test('logs audit event when deleting a connector', async () => {
+ await actionsClient.delete({ id: '1' });
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_delete',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: '1', type: 'action' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to delete a connector', async () => {
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(actionsClient.delete({ id: '1' })).rejects.toThrow();
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_delete',
+ outcome: 'failure',
+ }),
+ kibana: { saved_object: { id: '1', type: 'action' } },
+ error: { code: 'Error', message: 'Unauthorized' },
+ })
+ );
+ });
+ });
+
test('calls unsecuredSavedObjectsClient with id', async () => {
const expectedResult = Symbol();
unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult);
@@ -880,42 +1186,43 @@ describe('delete()', () => {
});
describe('update()', () => {
+ function updateOperation(): ReturnType
{
+ actionTypeRegistry.register({
+ id: 'my-action-type',
+ name: 'My action type',
+ minimumLicenseRequired: 'basic',
+ executor,
+ });
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'action',
+ attributes: {
+ actionTypeId: 'my-action-type',
+ },
+ references: [],
+ });
+ unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
+ id: 'my-action',
+ type: 'action',
+ attributes: {
+ actionTypeId: 'my-action-type',
+ name: 'my name',
+ config: {},
+ secrets: {},
+ },
+ references: [],
+ });
+ return actionsClient.update({
+ id: 'my-action',
+ action: {
+ name: 'my name',
+ config: {},
+ secrets: {},
+ },
+ });
+ }
+
describe('authorization', () => {
- function updateOperation(): ReturnType {
- actionTypeRegistry.register({
- id: 'my-action-type',
- name: 'My action type',
- minimumLicenseRequired: 'basic',
- executor,
- });
- unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
- id: '1',
- type: 'action',
- attributes: {
- actionTypeId: 'my-action-type',
- },
- references: [],
- });
- unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
- id: 'my-action',
- type: 'action',
- attributes: {
- actionTypeId: 'my-action-type',
- name: 'my name',
- config: {},
- secrets: {},
- },
- references: [],
- });
- return actionsClient.update({
- id: 'my-action',
- action: {
- name: 'my name',
- config: {},
- secrets: {},
- },
- });
- }
test('ensures user is authorised to update actions', async () => {
await updateOperation();
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update');
@@ -934,6 +1241,39 @@ describe('update()', () => {
});
});
+ describe('auditLogger', () => {
+ test('logs audit event when updating a connector', async () => {
+ await updateOperation();
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_update',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: 'my-action', type: 'action' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to update a connector', async () => {
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(updateOperation()).rejects.toThrow();
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'connector_update',
+ outcome: 'failure',
+ }),
+ kibana: { saved_object: { id: 'my-action', type: 'action' } },
+ error: { code: 'Error', message: 'Unauthorized' },
+ })
+ );
+ });
+ });
+
test('updates an action with all given properties', async () => {
actionTypeRegistry.register({
id: 'my-action-type',
diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts
index 0d41b520501add..ab693dc340c927 100644
--- a/x-pack/plugins/actions/server/actions_client.ts
+++ b/x-pack/plugins/actions/server/actions_client.ts
@@ -4,16 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from '@hapi/boom';
+
+import { i18n } from '@kbn/i18n';
+import { omitBy, isUndefined } from 'lodash';
import {
ILegacyScopedClusterClient,
SavedObjectsClientContract,
SavedObjectAttributes,
SavedObject,
KibanaRequest,
-} from 'src/core/server';
-
-import { i18n } from '@kbn/i18n';
-import { omitBy, isUndefined } from 'lodash';
+ SavedObjectsUtils,
+} from '../../../../src/core/server';
+import { AuditLogger, EventOutcome } from '../../security/server';
+import { ActionType } from '../common';
import { ActionTypeRegistry } from './action_type_registry';
import { validateConfig, validateSecrets, ActionExecutorContract } from './lib';
import {
@@ -30,11 +33,11 @@ import {
ExecuteOptions as EnqueueExecutionOptions,
} from './create_execute_function';
import { ActionsAuthorization } from './authorization/actions_authorization';
-import { ActionType } from '../common';
import {
getAuthorizationModeBySource,
AuthorizationMode,
} from './authorization/get_authorization_mode_by_source';
+import { connectorAuditEvent, ConnectorAuditAction } from './lib/audit_events';
// We are assuming there won't be many actions. This is why we will load
// all the actions in advance and assume the total count to not go over 10000.
@@ -65,6 +68,7 @@ interface ConstructorOptions {
executionEnqueuer: ExecutionEnqueuer;
request: KibanaRequest;
authorization: ActionsAuthorization;
+ auditLogger?: AuditLogger;
}
interface UpdateOptions {
@@ -82,6 +86,7 @@ export class ActionsClient {
private readonly request: KibanaRequest;
private readonly authorization: ActionsAuthorization;
private readonly executionEnqueuer: ExecutionEnqueuer;
+ private readonly auditLogger?: AuditLogger;
constructor({
actionTypeRegistry,
@@ -93,6 +98,7 @@ export class ActionsClient {
executionEnqueuer,
request,
authorization,
+ auditLogger,
}: ConstructorOptions) {
this.actionTypeRegistry = actionTypeRegistry;
this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient;
@@ -103,6 +109,7 @@ export class ActionsClient {
this.executionEnqueuer = executionEnqueuer;
this.request = request;
this.authorization = authorization;
+ this.auditLogger = auditLogger;
}
/**
@@ -111,7 +118,20 @@ export class ActionsClient {
public async create({
action: { actionTypeId, name, config, secrets },
}: CreateOptions): Promise {
- await this.authorization.ensureAuthorized('create', actionTypeId);
+ const id = SavedObjectsUtils.generateId();
+
+ try {
+ await this.authorization.ensureAuthorized('create', actionTypeId);
+ } catch (error) {
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.CREATE,
+ savedObject: { type: 'action', id },
+ error,
+ })
+ );
+ throw error;
+ }
const actionType = this.actionTypeRegistry.get(actionTypeId);
const validatedActionTypeConfig = validateConfig(actionType, config);
@@ -119,12 +139,24 @@ export class ActionsClient {
this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
- const result = await this.unsecuredSavedObjectsClient.create('action', {
- actionTypeId,
- name,
- config: validatedActionTypeConfig as SavedObjectAttributes,
- secrets: validatedActionTypeSecrets as SavedObjectAttributes,
- });
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.CREATE,
+ savedObject: { type: 'action', id },
+ outcome: EventOutcome.UNKNOWN,
+ })
+ );
+
+ const result = await this.unsecuredSavedObjectsClient.create(
+ 'action',
+ {
+ actionTypeId,
+ name,
+ config: validatedActionTypeConfig as SavedObjectAttributes,
+ secrets: validatedActionTypeSecrets as SavedObjectAttributes,
+ },
+ { id }
+ );
return {
id: result.id,
@@ -139,21 +171,32 @@ export class ActionsClient {
* Update action
*/
public async update({ id, action }: UpdateOptions): Promise {
- await this.authorization.ensureAuthorized('update');
-
- if (
- this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !==
- undefined
- ) {
- throw new PreconfiguredActionDisabledModificationError(
- i18n.translate('xpack.actions.serverSideErrors.predefinedActionUpdateDisabled', {
- defaultMessage: 'Preconfigured action {id} is not allowed to update.',
- values: {
- id,
- },
- }),
- 'update'
+ try {
+ await this.authorization.ensureAuthorized('update');
+
+ if (
+ this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !==
+ undefined
+ ) {
+ throw new PreconfiguredActionDisabledModificationError(
+ i18n.translate('xpack.actions.serverSideErrors.predefinedActionUpdateDisabled', {
+ defaultMessage: 'Preconfigured action {id} is not allowed to update.',
+ values: {
+ id,
+ },
+ }),
+ 'update'
+ );
+ }
+ } catch (error) {
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.UPDATE,
+ savedObject: { type: 'action', id },
+ error,
+ })
);
+ throw error;
}
const {
attributes,
@@ -168,6 +211,14 @@ export class ActionsClient {
this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.UPDATE,
+ savedObject: { type: 'action', id },
+ outcome: EventOutcome.UNKNOWN,
+ })
+ );
+
const result = await this.unsecuredSavedObjectsClient.create(
'action',
{
@@ -201,12 +252,30 @@ export class ActionsClient {
* Get an action
*/
public async get({ id }: { id: string }): Promise {
- await this.authorization.ensureAuthorized('get');
+ try {
+ await this.authorization.ensureAuthorized('get');
+ } catch (error) {
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.GET,
+ savedObject: { type: 'action', id },
+ error,
+ })
+ );
+ throw error;
+ }
const preconfiguredActionsList = this.preconfiguredActions.find(
(preconfiguredAction) => preconfiguredAction.id === id
);
if (preconfiguredActionsList !== undefined) {
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.GET,
+ savedObject: { type: 'action', id },
+ })
+ );
+
return {
id,
actionTypeId: preconfiguredActionsList.actionTypeId,
@@ -214,8 +283,16 @@ export class ActionsClient {
isPreconfigured: true,
};
}
+
const result = await this.unsecuredSavedObjectsClient.get('action', id);
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.GET,
+ savedObject: { type: 'action', id },
+ })
+ );
+
return {
id,
actionTypeId: result.attributes.actionTypeId,
@@ -229,7 +306,17 @@ export class ActionsClient {
* Get all actions with preconfigured list
*/
public async getAll(): Promise {
- await this.authorization.ensureAuthorized('get');
+ try {
+ await this.authorization.ensureAuthorized('get');
+ } catch (error) {
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.FIND,
+ error,
+ })
+ );
+ throw error;
+ }
const savedObjectsActions = (
await this.unsecuredSavedObjectsClient.find({
@@ -238,6 +325,15 @@ export class ActionsClient {
})
).saved_objects.map(actionFromSavedObject);
+ savedObjectsActions.forEach(({ id }) =>
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.FIND,
+ savedObject: { type: 'action', id },
+ })
+ )
+ );
+
const mergedResult = [
...savedObjectsActions,
...this.preconfiguredActions.map((preconfiguredAction) => ({
@@ -258,7 +354,20 @@ export class ActionsClient {
* Get bulk actions with preconfigured list
*/
public async getBulk(ids: string[]): Promise {
- await this.authorization.ensureAuthorized('get');
+ try {
+ await this.authorization.ensureAuthorized('get');
+ } catch (error) {
+ ids.forEach((id) =>
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.GET,
+ savedObject: { type: 'action', id },
+ error,
+ })
+ )
+ );
+ throw error;
+ }
const actionResults = new Array();
for (const actionId of ids) {
@@ -283,6 +392,17 @@ export class ActionsClient {
const bulkGetOpts = actionSavedObjectsIds.map((id) => ({ id, type: 'action' }));
const bulkGetResult = await this.unsecuredSavedObjectsClient.bulkGet(bulkGetOpts);
+ bulkGetResult.saved_objects.forEach(({ id, error }) => {
+ if (!error && this.auditLogger) {
+ this.auditLogger.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.GET,
+ savedObject: { type: 'action', id },
+ })
+ );
+ }
+ });
+
for (const action of bulkGetResult.saved_objects) {
if (action.error) {
throw Boom.badRequest(
@@ -298,22 +418,42 @@ export class ActionsClient {
* Delete action
*/
public async delete({ id }: { id: string }) {
- await this.authorization.ensureAuthorized('delete');
-
- if (
- this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !==
- undefined
- ) {
- throw new PreconfiguredActionDisabledModificationError(
- i18n.translate('xpack.actions.serverSideErrors.predefinedActionDeleteDisabled', {
- defaultMessage: 'Preconfigured action {id} is not allowed to delete.',
- values: {
- id,
- },
- }),
- 'delete'
+ try {
+ await this.authorization.ensureAuthorized('delete');
+
+ if (
+ this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !==
+ undefined
+ ) {
+ throw new PreconfiguredActionDisabledModificationError(
+ i18n.translate('xpack.actions.serverSideErrors.predefinedActionDeleteDisabled', {
+ defaultMessage: 'Preconfigured action {id} is not allowed to delete.',
+ values: {
+ id,
+ },
+ }),
+ 'delete'
+ );
+ }
+ } catch (error) {
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.DELETE,
+ savedObject: { type: 'action', id },
+ error,
+ })
);
+ throw error;
}
+
+ this.auditLogger?.log(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.DELETE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'action', id },
+ })
+ );
+
return await this.unsecuredSavedObjectsClient.delete('action', id);
}
diff --git a/x-pack/plugins/actions/server/lib/audit_events.test.ts b/x-pack/plugins/actions/server/lib/audit_events.test.ts
new file mode 100644
index 00000000000000..6c2fd99c2eafdb
--- /dev/null
+++ b/x-pack/plugins/actions/server/lib/audit_events.test.ts
@@ -0,0 +1,93 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EventOutcome } from '../../../security/server/audit';
+import { ConnectorAuditAction, connectorAuditEvent } from './audit_events';
+
+describe('#connectorAuditEvent', () => {
+ test('creates event with `unknown` outcome', () => {
+ expect(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.CREATE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'action', id: 'ACTION_ID' },
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "error": undefined,
+ "event": Object {
+ "action": "connector_create",
+ "category": "database",
+ "outcome": "unknown",
+ "type": "creation",
+ },
+ "kibana": Object {
+ "saved_object": Object {
+ "id": "ACTION_ID",
+ "type": "action",
+ },
+ },
+ "message": "User is creating connector [id=ACTION_ID]",
+ }
+ `);
+ });
+
+ test('creates event with `success` outcome', () => {
+ expect(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.CREATE,
+ savedObject: { type: 'action', id: 'ACTION_ID' },
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "error": undefined,
+ "event": Object {
+ "action": "connector_create",
+ "category": "database",
+ "outcome": "success",
+ "type": "creation",
+ },
+ "kibana": Object {
+ "saved_object": Object {
+ "id": "ACTION_ID",
+ "type": "action",
+ },
+ },
+ "message": "User has created connector [id=ACTION_ID]",
+ }
+ `);
+ });
+
+ test('creates event with `failure` outcome', () => {
+ expect(
+ connectorAuditEvent({
+ action: ConnectorAuditAction.CREATE,
+ savedObject: { type: 'action', id: 'ACTION_ID' },
+ error: new Error('ERROR_MESSAGE'),
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "error": Object {
+ "code": "Error",
+ "message": "ERROR_MESSAGE",
+ },
+ "event": Object {
+ "action": "connector_create",
+ "category": "database",
+ "outcome": "failure",
+ "type": "creation",
+ },
+ "kibana": Object {
+ "saved_object": Object {
+ "id": "ACTION_ID",
+ "type": "action",
+ },
+ },
+ "message": "Failed attempt to create connector [id=ACTION_ID]",
+ }
+ `);
+ });
+});
diff --git a/x-pack/plugins/actions/server/lib/audit_events.ts b/x-pack/plugins/actions/server/lib/audit_events.ts
new file mode 100644
index 00000000000000..7d25b5c0cd479a
--- /dev/null
+++ b/x-pack/plugins/actions/server/lib/audit_events.ts
@@ -0,0 +1,76 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AuditEvent, EventOutcome, EventCategory, EventType } from '../../../security/server';
+
+export enum ConnectorAuditAction {
+ CREATE = 'connector_create',
+ GET = 'connector_get',
+ UPDATE = 'connector_update',
+ DELETE = 'connector_delete',
+ FIND = 'connector_find',
+ EXECUTE = 'connector_execute',
+}
+
+type VerbsTuple = [string, string, string];
+
+const eventVerbs: Record = {
+ connector_create: ['create', 'creating', 'created'],
+ connector_get: ['access', 'accessing', 'accessed'],
+ connector_update: ['update', 'updating', 'updated'],
+ connector_delete: ['delete', 'deleting', 'deleted'],
+ connector_find: ['access', 'accessing', 'accessed'],
+ connector_execute: ['execute', 'executing', 'executed'],
+};
+
+const eventTypes: Record = {
+ connector_create: EventType.CREATION,
+ connector_get: EventType.ACCESS,
+ connector_update: EventType.CHANGE,
+ connector_delete: EventType.DELETION,
+ connector_find: EventType.ACCESS,
+ connector_execute: undefined,
+};
+
+export interface ConnectorAuditEventParams {
+ action: ConnectorAuditAction;
+ outcome?: EventOutcome;
+ savedObject?: NonNullable['saved_object'];
+ error?: Error;
+}
+
+export function connectorAuditEvent({
+ action,
+ savedObject,
+ outcome,
+ error,
+}: ConnectorAuditEventParams): AuditEvent {
+ const doc = savedObject ? `connector [id=${savedObject.id}]` : 'a connector';
+ const [present, progressive, past] = eventVerbs[action];
+ const message = error
+ ? `Failed attempt to ${present} ${doc}`
+ : outcome === EventOutcome.UNKNOWN
+ ? `User is ${progressive} ${doc}`
+ : `User has ${past} ${doc}`;
+ const type = eventTypes[action];
+
+ return {
+ message,
+ event: {
+ action,
+ category: EventCategory.DATABASE,
+ type,
+ outcome: outcome ?? (error ? EventOutcome.FAILURE : EventOutcome.SUCCESS),
+ },
+ kibana: {
+ saved_object: savedObject,
+ },
+ error: error && {
+ code: error.name,
+ message: error.message,
+ },
+ };
+}
diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts
index e61936321b8e0a..6e37d4bd7a92aa 100644
--- a/x-pack/plugins/actions/server/plugin.ts
+++ b/x-pack/plugins/actions/server/plugin.ts
@@ -314,6 +314,7 @@ export class ActionsPlugin implements Plugin, Plugi
isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!,
preconfiguredActions,
}),
+ auditLogger: this.security?.audit.asScoped(request),
});
};
@@ -439,6 +440,7 @@ export class ActionsPlugin implements Plugin, Plugi
preconfiguredActions,
actionExecutor,
instantiateAuthorization,
+ security,
} = this;
return async function actionsRouteHandlerContext(context, request) {
@@ -468,6 +470,7 @@ export class ActionsPlugin implements Plugin, Plugi
isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!,
preconfiguredActions,
}),
+ auditLogger: security?.audit.asScoped(request),
});
},
listTypes: actionTypeRegistry!.list.bind(actionTypeRegistry!),
diff --git a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts
index c83e24c5a45f4f..d697817be734b7 100644
--- a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts
@@ -13,7 +13,8 @@ import {
SavedObjectReference,
SavedObject,
PluginInitializerContext,
-} from 'src/core/server';
+ SavedObjectsUtils,
+} from '../../../../../src/core/server';
import { esKuery } from '../../../../../src/plugins/data/server';
import { ActionsClient, ActionsAuthorization } from '../../../actions/server';
import {
@@ -44,10 +45,12 @@ import { IEventLogClient } from '../../../../plugins/event_log/server';
import { parseIsoOrRelativeDate } from '../lib/iso_or_relative_date';
import { alertInstanceSummaryFromEventLog } from '../lib/alert_instance_summary_from_event_log';
import { IEvent } from '../../../event_log/server';
+import { AuditLogger, EventOutcome } from '../../../security/server';
import { parseDuration } from '../../common/parse_duration';
import { retryIfConflicts } from '../lib/retry_if_conflicts';
import { partiallyUpdateAlert } from '../saved_objects';
import { markApiKeyForInvalidation } from '../invalidate_pending_api_keys/mark_api_key_for_invalidation';
+import { alertAuditEvent, AlertAuditAction } from './audit_events';
export interface RegistryAlertTypeWithAuth extends RegistryAlertType {
authorizedConsumers: string[];
@@ -75,6 +78,7 @@ export interface ConstructorOptions {
getActionsClient: () => Promise;
getEventLogClient: () => Promise;
kibanaVersion: PluginInitializerContext['env']['packageInfo']['version'];
+ auditLogger?: AuditLogger;
}
export interface MuteOptions extends IndexType {
@@ -176,6 +180,7 @@ export class AlertsClient {
private readonly getEventLogClient: () => Promise;
private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient;
private readonly kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version'];
+ private readonly auditLogger?: AuditLogger;
constructor({
alertTypeRegistry,
@@ -192,6 +197,7 @@ export class AlertsClient {
actionsAuthorization,
getEventLogClient,
kibanaVersion,
+ auditLogger,
}: ConstructorOptions) {
this.logger = logger;
this.getUserName = getUserName;
@@ -207,14 +213,28 @@ export class AlertsClient {
this.actionsAuthorization = actionsAuthorization;
this.getEventLogClient = getEventLogClient;
this.kibanaVersion = kibanaVersion;
+ this.auditLogger = auditLogger;
}
public async create({ data, options }: CreateOptions): Promise {
- await this.authorization.ensureAuthorized(
- data.alertTypeId,
- data.consumer,
- WriteOperations.Create
- );
+ const id = SavedObjectsUtils.generateId();
+
+ try {
+ await this.authorization.ensureAuthorized(
+ data.alertTypeId,
+ data.consumer,
+ WriteOperations.Create
+ );
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.CREATE,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
+ }
// Throws an error if alert type isn't registered
const alertType = this.alertTypeRegistry.get(data.alertTypeId);
@@ -248,6 +268,15 @@ export class AlertsClient {
error: null,
},
};
+
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.CREATE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id },
+ })
+ );
+
let createdAlert: SavedObject;
try {
createdAlert = await this.unsecuredSavedObjectsClient.create(
@@ -256,6 +285,7 @@ export class AlertsClient {
{
...options,
references,
+ id,
}
);
} catch (e) {
@@ -297,10 +327,27 @@ export class AlertsClient {
public async get({ id }: { id: string }): Promise {
const result = await this.unsecuredSavedObjectsClient.get('alert', id);
- await this.authorization.ensureAuthorized(
- result.attributes.alertTypeId,
- result.attributes.consumer,
- ReadOperations.Get
+ try {
+ await this.authorization.ensureAuthorized(
+ result.attributes.alertTypeId,
+ result.attributes.consumer,
+ ReadOperations.Get
+ );
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.GET,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
+ }
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.GET,
+ savedObject: { type: 'alert', id },
+ })
);
return this.getAlertFromRaw(result.id, result.attributes, result.references);
}
@@ -370,11 +417,23 @@ export class AlertsClient {
public async find({
options: { fields, ...options } = {},
}: { options?: FindOptions } = {}): Promise {
+ let authorizationTuple;
+ try {
+ authorizationTuple = await this.authorization.getFindAuthorizationFilter();
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.FIND,
+ error,
+ })
+ );
+ throw error;
+ }
const {
filter: authorizationFilter,
ensureAlertTypeIsAuthorized,
logSuccessfulAuthorization,
- } = await this.authorization.getFindAuthorizationFilter();
+ } = authorizationTuple;
const {
page,
@@ -392,7 +451,18 @@ export class AlertsClient {
});
const authorizedData = data.map(({ id, attributes, references }) => {
- ensureAlertTypeIsAuthorized(attributes.alertTypeId, attributes.consumer);
+ try {
+ ensureAlertTypeIsAuthorized(attributes.alertTypeId, attributes.consumer);
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.FIND,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
+ }
return this.getAlertFromRaw(
id,
fields ? (pick(attributes, fields) as RawAlert) : attributes,
@@ -400,6 +470,15 @@ export class AlertsClient {
);
});
+ authorizedData.forEach(({ id }) =>
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.FIND,
+ savedObject: { type: 'alert', id },
+ })
+ )
+ );
+
logSuccessfulAuthorization();
return {
@@ -473,10 +552,29 @@ export class AlertsClient {
attributes = alert.attributes;
}
- await this.authorization.ensureAuthorized(
- attributes.alertTypeId,
- attributes.consumer,
- WriteOperations.Delete
+ try {
+ await this.authorization.ensureAuthorized(
+ attributes.alertTypeId,
+ attributes.consumer,
+ WriteOperations.Delete
+ );
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.DELETE,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
+ }
+
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.DELETE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id },
+ })
);
const removeResult = await this.unsecuredSavedObjectsClient.delete('alert', id);
@@ -520,10 +618,30 @@ export class AlertsClient {
// Still attempt to load the object using SOC
alertSavedObject = await this.unsecuredSavedObjectsClient.get('alert', id);
}
- await this.authorization.ensureAuthorized(
- alertSavedObject.attributes.alertTypeId,
- alertSavedObject.attributes.consumer,
- WriteOperations.Update
+
+ try {
+ await this.authorization.ensureAuthorized(
+ alertSavedObject.attributes.alertTypeId,
+ alertSavedObject.attributes.consumer,
+ WriteOperations.Update
+ );
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.UPDATE,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
+ }
+
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.UPDATE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id },
+ })
);
const updateResult = await this.updateAlert({ id, data }, alertSavedObject);
@@ -658,14 +776,28 @@ export class AlertsClient {
attributes = alert.attributes;
version = alert.version;
}
- await this.authorization.ensureAuthorized(
- attributes.alertTypeId,
- attributes.consumer,
- WriteOperations.UpdateApiKey
- );
- if (attributes.actions.length && !this.authorization.shouldUseLegacyAuthorization(attributes)) {
- await this.actionsAuthorization.ensureAuthorized('execute');
+ try {
+ await this.authorization.ensureAuthorized(
+ attributes.alertTypeId,
+ attributes.consumer,
+ WriteOperations.UpdateApiKey
+ );
+ if (
+ attributes.actions.length &&
+ !this.authorization.shouldUseLegacyAuthorization(attributes)
+ ) {
+ await this.actionsAuthorization.ensureAuthorized('execute');
+ }
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.UPDATE_API_KEY,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
}
const username = await this.getUserName();
@@ -678,6 +810,15 @@ export class AlertsClient {
updatedAt: new Date().toISOString(),
updatedBy: username,
});
+
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.UPDATE_API_KEY,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id },
+ })
+ );
+
try {
await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version });
} catch (e) {
@@ -732,16 +873,35 @@ export class AlertsClient {
version = alert.version;
}
- await this.authorization.ensureAuthorized(
- attributes.alertTypeId,
- attributes.consumer,
- WriteOperations.Enable
- );
+ try {
+ await this.authorization.ensureAuthorized(
+ attributes.alertTypeId,
+ attributes.consumer,
+ WriteOperations.Enable
+ );
- if (attributes.actions.length) {
- await this.actionsAuthorization.ensureAuthorized('execute');
+ if (attributes.actions.length) {
+ await this.actionsAuthorization.ensureAuthorized('execute');
+ }
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.ENABLE,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
}
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.ENABLE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id },
+ })
+ );
+
if (attributes.enabled === false) {
const username = await this.getUserName();
const updateAttributes = this.updateMeta({
@@ -816,10 +976,29 @@ export class AlertsClient {
version = alert.version;
}
- await this.authorization.ensureAuthorized(
- attributes.alertTypeId,
- attributes.consumer,
- WriteOperations.Disable
+ try {
+ await this.authorization.ensureAuthorized(
+ attributes.alertTypeId,
+ attributes.consumer,
+ WriteOperations.Disable
+ );
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.DISABLE,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
+ }
+
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.DISABLE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id },
+ })
);
if (attributes.enabled === true) {
@@ -866,16 +1045,36 @@ export class AlertsClient {
'alert',
id
);
- await this.authorization.ensureAuthorized(
- attributes.alertTypeId,
- attributes.consumer,
- WriteOperations.MuteAll
- );
- if (attributes.actions.length) {
- await this.actionsAuthorization.ensureAuthorized('execute');
+ try {
+ await this.authorization.ensureAuthorized(
+ attributes.alertTypeId,
+ attributes.consumer,
+ WriteOperations.MuteAll
+ );
+
+ if (attributes.actions.length) {
+ await this.actionsAuthorization.ensureAuthorized('execute');
+ }
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.MUTE,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
}
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.MUTE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id },
+ })
+ );
+
const updateAttributes = this.updateMeta({
muteAll: true,
mutedInstanceIds: [],
@@ -905,16 +1104,36 @@ export class AlertsClient {
'alert',
id
);
- await this.authorization.ensureAuthorized(
- attributes.alertTypeId,
- attributes.consumer,
- WriteOperations.UnmuteAll
- );
- if (attributes.actions.length) {
- await this.actionsAuthorization.ensureAuthorized('execute');
+ try {
+ await this.authorization.ensureAuthorized(
+ attributes.alertTypeId,
+ attributes.consumer,
+ WriteOperations.UnmuteAll
+ );
+
+ if (attributes.actions.length) {
+ await this.actionsAuthorization.ensureAuthorized('execute');
+ }
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.UNMUTE,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
}
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.UNMUTE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id },
+ })
+ );
+
const updateAttributes = this.updateMeta({
muteAll: false,
mutedInstanceIds: [],
@@ -945,16 +1164,35 @@ export class AlertsClient {
alertId
);
- await this.authorization.ensureAuthorized(
- attributes.alertTypeId,
- attributes.consumer,
- WriteOperations.MuteInstance
- );
+ try {
+ await this.authorization.ensureAuthorized(
+ attributes.alertTypeId,
+ attributes.consumer,
+ WriteOperations.MuteInstance
+ );
- if (attributes.actions.length) {
- await this.actionsAuthorization.ensureAuthorized('execute');
+ if (attributes.actions.length) {
+ await this.actionsAuthorization.ensureAuthorized('execute');
+ }
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.MUTE_INSTANCE,
+ savedObject: { type: 'alert', id: alertId },
+ error,
+ })
+ );
+ throw error;
}
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.MUTE_INSTANCE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id: alertId },
+ })
+ );
+
const mutedInstanceIds = attributes.mutedInstanceIds || [];
if (!attributes.muteAll && !mutedInstanceIds.includes(alertInstanceId)) {
mutedInstanceIds.push(alertInstanceId);
@@ -991,15 +1229,34 @@ export class AlertsClient {
alertId
);
- await this.authorization.ensureAuthorized(
- attributes.alertTypeId,
- attributes.consumer,
- WriteOperations.UnmuteInstance
- );
- if (attributes.actions.length) {
- await this.actionsAuthorization.ensureAuthorized('execute');
+ try {
+ await this.authorization.ensureAuthorized(
+ attributes.alertTypeId,
+ attributes.consumer,
+ WriteOperations.UnmuteInstance
+ );
+ if (attributes.actions.length) {
+ await this.actionsAuthorization.ensureAuthorized('execute');
+ }
+ } catch (error) {
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.UNMUTE_INSTANCE,
+ savedObject: { type: 'alert', id: alertId },
+ error,
+ })
+ );
+ throw error;
}
+ this.auditLogger?.log(
+ alertAuditEvent({
+ action: AlertAuditAction.UNMUTE_INSTANCE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id: alertId },
+ })
+ );
+
const mutedInstanceIds = attributes.mutedInstanceIds || [];
if (!attributes.muteAll && mutedInstanceIds.includes(alertInstanceId)) {
await this.unsecuredSavedObjectsClient.update(
diff --git a/x-pack/plugins/alerts/server/alerts_client/audit_events.test.ts b/x-pack/plugins/alerts/server/alerts_client/audit_events.test.ts
new file mode 100644
index 00000000000000..9cd48248320c08
--- /dev/null
+++ b/x-pack/plugins/alerts/server/alerts_client/audit_events.test.ts
@@ -0,0 +1,93 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EventOutcome } from '../../../security/server/audit';
+import { AlertAuditAction, alertAuditEvent } from './audit_events';
+
+describe('#alertAuditEvent', () => {
+ test('creates event with `unknown` outcome', () => {
+ expect(
+ alertAuditEvent({
+ action: AlertAuditAction.CREATE,
+ outcome: EventOutcome.UNKNOWN,
+ savedObject: { type: 'alert', id: 'ALERT_ID' },
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "error": undefined,
+ "event": Object {
+ "action": "alert_create",
+ "category": "database",
+ "outcome": "unknown",
+ "type": "creation",
+ },
+ "kibana": Object {
+ "saved_object": Object {
+ "id": "ALERT_ID",
+ "type": "alert",
+ },
+ },
+ "message": "User is creating alert [id=ALERT_ID]",
+ }
+ `);
+ });
+
+ test('creates event with `success` outcome', () => {
+ expect(
+ alertAuditEvent({
+ action: AlertAuditAction.CREATE,
+ savedObject: { type: 'alert', id: 'ALERT_ID' },
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "error": undefined,
+ "event": Object {
+ "action": "alert_create",
+ "category": "database",
+ "outcome": "success",
+ "type": "creation",
+ },
+ "kibana": Object {
+ "saved_object": Object {
+ "id": "ALERT_ID",
+ "type": "alert",
+ },
+ },
+ "message": "User has created alert [id=ALERT_ID]",
+ }
+ `);
+ });
+
+ test('creates event with `failure` outcome', () => {
+ expect(
+ alertAuditEvent({
+ action: AlertAuditAction.CREATE,
+ savedObject: { type: 'alert', id: 'ALERT_ID' },
+ error: new Error('ERROR_MESSAGE'),
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "error": Object {
+ "code": "Error",
+ "message": "ERROR_MESSAGE",
+ },
+ "event": Object {
+ "action": "alert_create",
+ "category": "database",
+ "outcome": "failure",
+ "type": "creation",
+ },
+ "kibana": Object {
+ "saved_object": Object {
+ "id": "ALERT_ID",
+ "type": "alert",
+ },
+ },
+ "message": "Failed attempt to create alert [id=ALERT_ID]",
+ }
+ `);
+ });
+});
diff --git a/x-pack/plugins/alerts/server/alerts_client/audit_events.ts b/x-pack/plugins/alerts/server/alerts_client/audit_events.ts
new file mode 100644
index 00000000000000..f3e39598240847
--- /dev/null
+++ b/x-pack/plugins/alerts/server/alerts_client/audit_events.ts
@@ -0,0 +1,94 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AuditEvent, EventOutcome, EventCategory, EventType } from '../../../security/server';
+
+export enum AlertAuditAction {
+ CREATE = 'alert_create',
+ GET = 'alert_get',
+ UPDATE = 'alert_update',
+ UPDATE_API_KEY = 'alert_update_api_key',
+ ENABLE = 'alert_enable',
+ DISABLE = 'alert_disable',
+ DELETE = 'alert_delete',
+ FIND = 'alert_find',
+ MUTE = 'alert_mute',
+ UNMUTE = 'alert_unmute',
+ MUTE_INSTANCE = 'alert_instance_mute',
+ UNMUTE_INSTANCE = 'alert_instance_unmute',
+}
+
+type VerbsTuple = [string, string, string];
+
+const eventVerbs: Record = {
+ alert_create: ['create', 'creating', 'created'],
+ alert_get: ['access', 'accessing', 'accessed'],
+ alert_update: ['update', 'updating', 'updated'],
+ alert_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'],
+ alert_enable: ['enable', 'enabling', 'enabled'],
+ alert_disable: ['disable', 'disabling', 'disabled'],
+ alert_delete: ['delete', 'deleting', 'deleted'],
+ alert_find: ['access', 'accessing', 'accessed'],
+ alert_mute: ['mute', 'muting', 'muted'],
+ alert_unmute: ['unmute', 'unmuting', 'unmuted'],
+ alert_instance_mute: ['mute instance of', 'muting instance of', 'muted instance of'],
+ alert_instance_unmute: ['unmute instance of', 'unmuting instance of', 'unmuted instance of'],
+};
+
+const eventTypes: Record = {
+ alert_create: EventType.CREATION,
+ alert_get: EventType.ACCESS,
+ alert_update: EventType.CHANGE,
+ alert_update_api_key: EventType.CHANGE,
+ alert_enable: EventType.CHANGE,
+ alert_disable: EventType.CHANGE,
+ alert_delete: EventType.DELETION,
+ alert_find: EventType.ACCESS,
+ alert_mute: EventType.CHANGE,
+ alert_unmute: EventType.CHANGE,
+ alert_instance_mute: EventType.CHANGE,
+ alert_instance_unmute: EventType.CHANGE,
+};
+
+export interface AlertAuditEventParams {
+ action: AlertAuditAction;
+ outcome?: EventOutcome;
+ savedObject?: NonNullable['saved_object'];
+ error?: Error;
+}
+
+export function alertAuditEvent({
+ action,
+ savedObject,
+ outcome,
+ error,
+}: AlertAuditEventParams): AuditEvent {
+ const doc = savedObject ? `alert [id=${savedObject.id}]` : 'an alert';
+ const [present, progressive, past] = eventVerbs[action];
+ const message = error
+ ? `Failed attempt to ${present} ${doc}`
+ : outcome === EventOutcome.UNKNOWN
+ ? `User is ${progressive} ${doc}`
+ : `User has ${past} ${doc}`;
+ const type = eventTypes[action];
+
+ return {
+ message,
+ event: {
+ action,
+ category: EventCategory.DATABASE,
+ type,
+ outcome: outcome ?? (error ? EventOutcome.FAILURE : EventOutcome.SUCCESS),
+ },
+ kibana: {
+ saved_object: savedObject,
+ },
+ error: error && {
+ code: error.name,
+ message: error.message,
+ },
+ };
+}
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts
index dcbb33d849405a..b943a21ba9bb63 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts
@@ -14,15 +14,24 @@ import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization, ActionsClient } from '../../../../actions/server';
import { TaskStatus } from '../../../../task_manager/server';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
import { getBeforeSetup, setGlobalDate } from './lib';
import { RecoveredActionGroup } from '../../../common';
+jest.mock('../../../../../../src/core/server/saved_objects/service/lib/utils', () => ({
+ SavedObjectsUtils: {
+ generateId: () => 'mock-saved-object-id',
+ },
+}));
+
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -40,10 +49,12 @@ const alertsClientParams: jest.Mocked = {
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
+ auditLogger,
};
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -185,6 +196,62 @@ describe('create()', () => {
});
});
+ describe('auditLogger', () => {
+ test('logs audit event when creating an alert', async () => {
+ const data = getMockData({
+ enabled: false,
+ actions: [],
+ });
+ unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: data,
+ references: [],
+ });
+ await alertsClient.create({ data });
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_create',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: 'mock-saved-object-id', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to create an alert', async () => {
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(
+ alertsClient.create({
+ data: getMockData({
+ enabled: false,
+ actions: [],
+ }),
+ })
+ ).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_create',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: 'mock-saved-object-id',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
+
test('creates an alert', async () => {
const data = getMockData();
const createdAttributes = {
@@ -337,16 +404,17 @@ describe('create()', () => {
}
`);
expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(`
- Object {
- "references": Array [
- Object {
- "id": "1",
- "name": "action_0",
- "type": "action",
- },
- ],
- }
- `);
+ Object {
+ "id": "mock-saved-object-id",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "action_0",
+ "type": "action",
+ },
+ ],
+ }
+ `);
expect(taskManager.schedule).toHaveBeenCalledTimes(1);
expect(taskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(`
Array [
@@ -991,6 +1059,7 @@ describe('create()', () => {
},
},
{
+ id: 'mock-saved-object-id',
references: [
{
id: '1',
@@ -1113,6 +1182,7 @@ describe('create()', () => {
},
},
{
+ id: 'mock-saved-object-id',
references: [
{
id: '1',
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts
index e7b975aec8eb0b..a7ef008eaa2ee7 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts
@@ -12,6 +12,8 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { getBeforeSetup } from './lib';
const taskManager = taskManagerMock.createStart();
@@ -20,6 +22,7 @@ const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -37,10 +40,12 @@ const alertsClientParams: jest.Mocked = {
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
+ auditLogger,
};
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
describe('delete()', () => {
@@ -239,4 +244,43 @@ describe('delete()', () => {
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'delete');
});
});
+
+ describe('auditLogger', () => {
+ test('logs audit event when deleting an alert', async () => {
+ await alertsClient.delete({ id: '1' });
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_delete',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to delete an alert', async () => {
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(alertsClient.delete({ id: '1' })).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_delete',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts
index 8c9ab9494a50af..ce0688a5ab2ff4 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts
@@ -12,16 +12,18 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
-import { getBeforeSetup, setGlobalDate } from './lib';
import { InvalidatePendingApiKey } from '../../types';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
+import { getBeforeSetup, setGlobalDate } from './lib';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
-
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -39,10 +41,12 @@ const alertsClientParams: jest.Mocked = {
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
+ auditLogger,
};
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -109,6 +113,45 @@ describe('disable()', () => {
});
});
+ describe('auditLogger', () => {
+ test('logs audit event when disabling an alert', async () => {
+ await alertsClient.disable({ id: '1' });
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_disable',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to disable an alert', async () => {
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(alertsClient.disable({ id: '1' })).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_disable',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
+
test('disables an alert', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts
index feec1d1b9334a1..daac6689a183b2 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts
@@ -13,16 +13,18 @@ import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
import { TaskStatus } from '../../../../task_manager/server';
-import { getBeforeSetup, setGlobalDate } from './lib';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { InvalidatePendingApiKey } from '../../types';
+import { getBeforeSetup, setGlobalDate } from './lib';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
-
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -40,10 +42,12 @@ const alertsClientParams: jest.Mocked = {
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
+ auditLogger,
};
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -148,6 +152,45 @@ describe('enable()', () => {
});
});
+ describe('auditLogger', () => {
+ test('logs audit event when enabling an alert', async () => {
+ await alertsClient.enable({ id: '1' });
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_enable',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to enable an alert', async () => {
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(alertsClient.enable({ id: '1' })).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_enable',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
+
test('enables an alert', async () => {
const createdAt = new Date().toISOString();
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts
index 336cb536d702b5..232d48e258256a 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts
@@ -14,16 +14,18 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { getBeforeSetup, setGlobalDate } from './lib';
import { RecoveredActionGroup } from '../../../common';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
-
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -45,6 +47,7 @@ const alertsClientParams: jest.Mocked = {
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -251,4 +254,64 @@ describe('find()', () => {
expect(logSuccessfulAuthorization).toHaveBeenCalled();
});
});
+
+ describe('auditLogger', () => {
+ test('logs audit event when searching alerts', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ await alertsClient.find();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_find',
+ outcome: 'success',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to search alerts', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ authorization.getFindAuthorizationFilter.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(alertsClient.find()).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_find',
+ outcome: 'failure',
+ }),
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to search alert type', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ authorization.getFindAuthorizationFilter.mockResolvedValue({
+ ensureAlertTypeIsAuthorized: jest.fn(() => {
+ throw new Error('Unauthorized');
+ }),
+ logSuccessfulAuthorization: jest.fn(),
+ });
+
+ await expect(async () => await alertsClient.find()).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_find',
+ outcome: 'failure',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts
index 3f0c783f424d13..32ac57459795eb 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts
@@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { getBeforeSetup, setGlobalDate } from './lib';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
-
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = {
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -191,4 +194,61 @@ describe('get()', () => {
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'get');
});
});
+
+ describe('auditLogger', () => {
+ beforeEach(() => {
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ alertTypeId: '123',
+ schedule: { interval: '10s' },
+ params: {
+ bar: true,
+ },
+ actions: [],
+ },
+ references: [],
+ });
+ });
+
+ test('logs audit event when getting an alert', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ await alertsClient.get({ id: '1' });
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_get',
+ outcome: 'success',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to get an alert', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(alertsClient.get({ id: '1' })).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_get',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts
index 14ebca21355879..b3c3e1bdd2ede4 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts
@@ -12,6 +12,8 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { getBeforeSetup, setGlobalDate } from './lib';
const taskManager = taskManagerMock.createStart();
@@ -20,6 +22,7 @@ const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -41,6 +44,7 @@ const alertsClientParams: jest.Mocked = {
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -137,4 +141,85 @@ describe('muteAll()', () => {
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'muteAll');
});
});
+
+ describe('auditLogger', () => {
+ test('logs audit event when muting an alert', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ actions: [
+ {
+ group: 'default',
+ id: '1',
+ actionTypeId: '1',
+ actionRef: '1',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ muteAll: false,
+ },
+ references: [],
+ version: '123',
+ });
+ await alertsClient.muteAll({ id: '1' });
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_mute',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to mute an alert', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ actions: [
+ {
+ group: 'default',
+ id: '1',
+ actionTypeId: '1',
+ actionRef: '1',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ muteAll: false,
+ },
+ references: [],
+ version: '123',
+ });
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(alertsClient.muteAll({ id: '1' })).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_mute',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts
index c2188f128cb4d5..ec69dbdeac55f9 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts
@@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { getBeforeSetup, setGlobalDate } from './lib';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
-
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = {
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -180,4 +183,75 @@ describe('muteInstance()', () => {
);
});
});
+
+ describe('auditLogger', () => {
+ test('logs audit event when muting an alert instance', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ actions: [],
+ schedule: { interval: '10s' },
+ alertTypeId: '2',
+ enabled: true,
+ scheduledTaskId: 'task-123',
+ mutedInstanceIds: [],
+ },
+ version: '123',
+ references: [],
+ });
+ await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' });
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_instance_mute',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to mute an alert instance', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ actions: [],
+ schedule: { interval: '10s' },
+ alertTypeId: '2',
+ enabled: true,
+ scheduledTaskId: 'task-123',
+ mutedInstanceIds: [],
+ },
+ version: '123',
+ references: [],
+ });
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(
+ alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' })
+ ).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_instance_mute',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts
index d92304ab873be7..fd0157091e3a53 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts
@@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { getBeforeSetup, setGlobalDate } from './lib';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
-
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = {
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -138,4 +141,85 @@ describe('unmuteAll()', () => {
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'unmuteAll');
});
});
+
+ describe('auditLogger', () => {
+ test('logs audit event when unmuting an alert', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ actions: [
+ {
+ group: 'default',
+ id: '1',
+ actionTypeId: '1',
+ actionRef: '1',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ muteAll: false,
+ },
+ references: [],
+ version: '123',
+ });
+ await alertsClient.unmuteAll({ id: '1' });
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_unmute',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to unmute an alert', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ actions: [
+ {
+ group: 'default',
+ id: '1',
+ actionTypeId: '1',
+ actionRef: '1',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ muteAll: false,
+ },
+ references: [],
+ version: '123',
+ });
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(alertsClient.unmuteAll({ id: '1' })).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_unmute',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts
index 3486df98f2f057..c7d084a01a2a0e 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts
@@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { getBeforeSetup, setGlobalDate } from './lib';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
-
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = {
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -178,4 +181,75 @@ describe('unmuteInstance()', () => {
);
});
});
+
+ describe('auditLogger', () => {
+ test('logs audit event when unmuting an alert instance', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ actions: [],
+ schedule: { interval: '10s' },
+ alertTypeId: '2',
+ enabled: true,
+ scheduledTaskId: 'task-123',
+ mutedInstanceIds: [],
+ },
+ version: '123',
+ references: [],
+ });
+ await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' });
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_instance_unmute',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to unmute an alert instance', async () => {
+ const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger });
+ unsecuredSavedObjectsClient.get.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ actions: [],
+ schedule: { interval: '10s' },
+ alertTypeId: '2',
+ enabled: true,
+ scheduledTaskId: 'task-123',
+ mutedInstanceIds: [],
+ },
+ version: '123',
+ references: [],
+ });
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(
+ alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' })
+ ).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_instance_unmute',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts
index b42ee096777fe8..15fb1e2ec0092c 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts
@@ -18,15 +18,17 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { resolvable } from '../../test_utils';
import { ActionsAuthorization, ActionsClient } from '../../../../actions/server';
import { TaskStatus } from '../../../../task_manager/server';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { getBeforeSetup, setGlobalDate } from './lib';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
-
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -44,10 +46,12 @@ const alertsClientParams: jest.Mocked = {
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
+ auditLogger,
};
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -1302,4 +1306,89 @@ describe('update()', () => {
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'update');
});
});
+
+ describe('auditLogger', () => {
+ beforeEach(() => {
+ unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ enabled: true,
+ schedule: { interval: '10s' },
+ params: {
+ bar: true,
+ },
+ actions: [],
+ scheduledTaskId: 'task-123',
+ createdAt: new Date().toISOString(),
+ },
+ updated_at: new Date().toISOString(),
+ references: [],
+ });
+ });
+
+ test('logs audit event when updating an alert', async () => {
+ await alertsClient.update({
+ id: '1',
+ data: {
+ schedule: { interval: '10s' },
+ name: 'abc',
+ tags: ['foo'],
+ params: {
+ bar: true,
+ },
+ throttle: null,
+ actions: [],
+ },
+ });
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_update',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to update an alert', async () => {
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(
+ alertsClient.update({
+ id: '1',
+ data: {
+ schedule: { interval: '10s' },
+ name: 'abc',
+ tags: ['foo'],
+ params: {
+ bar: true,
+ },
+ throttle: null,
+ actions: [],
+ },
+ })
+ ).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ outcome: 'failure',
+ action: 'alert_update',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts
index ca5f44078f5138..bf21256bb8413e 100644
--- a/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts
+++ b/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts
@@ -12,8 +12,10 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
-import { getBeforeSetup, setGlobalDate } from './lib';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { InvalidatePendingApiKey } from '../../types';
+import { getBeforeSetup, setGlobalDate } from './lib';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
@@ -21,6 +23,7 @@ const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked = {
@@ -38,10 +41,12 @@ const alertsClientParams: jest.Mocked = {
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
+ auditLogger,
};
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
@@ -269,4 +274,44 @@ describe('updateApiKey()', () => {
);
});
});
+
+ describe('auditLogger', () => {
+ test('logs audit event when updating the API key of an alert', async () => {
+ await alertsClient.updateApiKey({ id: '1' });
+
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'alert_update_api_key',
+ outcome: 'unknown',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to update the API key of an alert', async () => {
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(alertsClient.updateApiKey({ id: '1' })).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ outcome: 'failure',
+ action: 'alert_update_api_key',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.ts b/x-pack/plugins/alerts/server/alerts_client_factory.ts
index 069703be72f8a5..9d71b5f817b2c0 100644
--- a/x-pack/plugins/alerts/server/alerts_client_factory.ts
+++ b/x-pack/plugins/alerts/server/alerts_client_factory.ts
@@ -100,6 +100,7 @@ export class AlertsClientFactory {
actionsAuthorization: actions.getActionsAuthorizationWithRequest(request),
namespace: this.spaceIdToNamespace(spaceId),
encryptedSavedObjectsClient: this.encryptedSavedObjectsClient,
+ auditLogger: securityPluginSetup?.audit.asScoped(request),
async getUserName() {
if (!securityPluginSetup) {
return null;
diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx
index bebd5bdabbae37..309cde4dd9f650 100644
--- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx
@@ -33,11 +33,9 @@ import { unit } from '../../../../style/variables';
import { ChartContainer } from '../../../shared/charts/chart_container';
import { EmptyMessage } from '../../../shared/EmptyMessage';
-type TransactionDistributionAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups/distribution'>;
+type TransactionDistributionAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/distribution'>;
-type DistributionApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups/distribution'>;
-
-type DistributionBucket = DistributionApiResponse['buckets'][0];
+type DistributionBucket = TransactionDistributionAPIResponse['buckets'][0];
interface IChartPoint {
x0: number;
diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx
index d90fe393c94a45..a633341ba2bb45 100644
--- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx
@@ -27,7 +27,7 @@ import { MaybeViewTraceLink } from './MaybeViewTraceLink';
import { TransactionTabs } from './TransactionTabs';
import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers';
-type DistributionApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups/distribution'>;
+type DistributionApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/distribution'>;
type DistributionBucket = DistributionApiResponse['buckets'][0];
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx
index 6b02a44dcc2f46..e4260a2533d36f 100644
--- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx
@@ -36,7 +36,7 @@ import { ImpactBar } from '../../../shared/ImpactBar';
import { ServiceOverviewTable } from '../service_overview_table';
type ServiceTransactionGroupItem = ValuesType<
- APIReturnType<'GET /api/apm/services/{serviceName}/overview_transaction_groups'>['transactionGroups']
+ APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/overview'>['transactionGroups']
>;
interface Props {
@@ -100,7 +100,7 @@ export function ServiceOverviewTransactionsTable(props: Props) {
return callApmApi({
endpoint:
- 'GET /api/apm/services/{serviceName}/overview_transaction_groups',
+ 'GET /api/apm/services/{serviceName}/transactions/groups/overview',
params: {
path: { serviceName },
query: {
diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx
index c14c31afe0445e..bc73a3acf41356 100644
--- a/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx
+++ b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx
@@ -10,7 +10,7 @@ import { APIReturnType } from '../../../../services/rest/createCallApmApi';
import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context';
import { TransactionList } from './';
-type TransactionGroup = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups'>['items'][0];
+type TransactionGroup = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups'>['items'][0];
export default {
title: 'app/TransactionOverview/TransactionList',
diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx
index 9774538b2a7a7e..ade0a0563b0dc3 100644
--- a/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx
@@ -20,7 +20,7 @@ import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt';
import { EmptyMessage } from '../../../shared/EmptyMessage';
import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink';
-type TransactionGroup = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups'>['items'][0];
+type TransactionGroup = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups'>['items'][0];
// Truncate both the link and the child span (the tooltip anchor.) The link so
// it doesn't overflow, and the anchor so we get the ellipsis.
diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts b/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts
index 78883ec2cf0d39..0ca2867852f262 100644
--- a/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts
+++ b/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts
@@ -9,7 +9,7 @@ import { APIReturnType } from '../../../services/rest/createCallApmApi';
import { useFetcher } from '../../../hooks/use_fetcher';
import { useUrlParams } from '../../../context/url_params_context/use_url_params';
-type TransactionsAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups'>;
+type TransactionsAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups'>;
const DEFAULT_RESPONSE: Partial = {
items: undefined,
@@ -25,7 +25,7 @@ export function useTransactionListFetcher() {
(callApmApi) => {
if (serviceName && start && end && transactionType) {
return callApmApi({
- endpoint: 'GET /api/apm/services/{serviceName}/transaction_groups',
+ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups',
params: {
path: { serviceName },
query: {
diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts
index ff744d763ecaed..81840dc52c1eca 100644
--- a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts
+++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts
@@ -20,7 +20,7 @@ export function useTransactionBreakdown() {
if (serviceName && start && end && transactionType) {
return callApmApi({
endpoint:
- 'GET /api/apm/services/{serviceName}/transaction_groups/breakdown',
+ 'GET /api/apm/services/{serviceName}/transaction/charts/breakdown',
params: {
path: { serviceName },
query: {
diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx
index 06a5e7baef79b9..4a388b13d7d224 100644
--- a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx
+++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx
@@ -45,7 +45,7 @@ export function TransactionErrorRateChart({
if (serviceName && start && end) {
return callApmApi({
endpoint:
- 'GET /api/apm/services/{serviceName}/transaction_groups/error_rate',
+ 'GET /api/apm/services/{serviceName}/transactions/charts/error_rate',
params: {
path: {
serviceName,
diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_charts_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_charts_fetcher.ts
index f5105e38b985e2..406a1a46335770 100644
--- a/x-pack/plugins/apm/public/hooks/use_transaction_charts_fetcher.ts
+++ b/x-pack/plugins/apm/public/hooks/use_transaction_charts_fetcher.ts
@@ -21,8 +21,7 @@ export function useTransactionChartsFetcher() {
(callApmApi) => {
if (serviceName && start && end) {
return callApmApi({
- endpoint:
- 'GET /api/apm/services/{serviceName}/transaction_groups/charts',
+ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts',
params: {
path: { serviceName },
query: {
diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts
index 74222e8ffe038c..b8968031e6922d 100644
--- a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts
+++ b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts
@@ -12,7 +12,7 @@ import { maybe } from '../../common/utils/maybe';
import { APIReturnType } from '../services/rest/createCallApmApi';
import { useUrlParams } from '../context/url_params_context/use_url_params';
-type APIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups/distribution'>;
+type APIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/distribution'>;
const INITIAL_DATA = {
buckets: [] as APIResponse['buckets'],
@@ -38,7 +38,7 @@ export function useTransactionDistributionFetcher() {
if (serviceName && start && end && transactionType && transactionName) {
const response = await callApmApi({
endpoint:
- 'GET /api/apm/services/{serviceName}/transaction_groups/distribution',
+ 'GET /api/apm/services/{serviceName}/transactions/charts/distribution',
params: {
path: {
serviceName,
diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_group.ts b/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts
similarity index 92%
rename from x-pack/plugins/apm/server/lib/errors/get_error_group.ts
rename to x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts
index 965cc28952b7ab..ff09855e63a8fc 100644
--- a/x-pack/plugins/apm/server/lib/errors/get_error_group.ts
+++ b/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts
@@ -14,8 +14,7 @@ import { rangeFilter } from '../../../common/utils/range_filter';
import { Setup, SetupTimeRange } from '../helpers/setup_request';
import { getTransaction } from '../transactions/get_transaction';
-// TODO: rename from "getErrorGroup" to "getErrorGroupSample" (since a single error is returned, not an errorGroup)
-export async function getErrorGroup({
+export async function getErrorGroupSample({
serviceName,
groupId,
setup,
diff --git a/x-pack/plugins/apm/server/lib/errors/queries.test.ts b/x-pack/plugins/apm/server/lib/errors/queries.test.ts
index fec59393726bf6..92f0abcfb77e77 100644
--- a/x-pack/plugins/apm/server/lib/errors/queries.test.ts
+++ b/x-pack/plugins/apm/server/lib/errors/queries.test.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { getErrorGroup } from './get_error_group';
+import { getErrorGroupSample } from './get_error_group_sample';
import { getErrorGroups } from './get_error_groups';
import {
SearchParamsMock,
@@ -20,7 +20,7 @@ describe('error queries', () => {
it('fetches a single error group', async () => {
mock = await inspectSearchParams((setup) =>
- getErrorGroup({
+ getErrorGroupSample({
groupId: 'groupId',
serviceName: 'serviceName',
setup,
diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_sample_for_group.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_sample_for_group.ts
deleted file mode 100644
index 7e1aad075fb161..00000000000000
--- a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_sample_for_group.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { maybe } from '../../../common/utils/maybe';
-import {
- SERVICE_NAME,
- TRANSACTION_NAME,
- TRANSACTION_SAMPLED,
-} from '../../../common/elasticsearch_fieldnames';
-import { ProcessorEvent } from '../../../common/processor_event';
-import { rangeFilter } from '../../../common/utils/range_filter';
-import { Setup, SetupTimeRange } from '../helpers/setup_request';
-
-export async function getTransactionSampleForGroup({
- serviceName,
- transactionName,
- setup,
-}: {
- serviceName: string;
- transactionName: string;
- setup: Setup & SetupTimeRange;
-}) {
- const { apmEventClient, start, end, esFilter } = setup;
-
- const filter = [
- {
- range: rangeFilter(start, end),
- },
- {
- term: {
- [SERVICE_NAME]: serviceName,
- },
- },
- {
- term: {
- [TRANSACTION_NAME]: transactionName,
- },
- },
- ...esFilter,
- ];
-
- const getSampledTransaction = async () => {
- const response = await apmEventClient.search({
- terminateAfter: 1,
- apm: {
- events: [ProcessorEvent.transaction],
- },
- body: {
- size: 1,
- query: {
- bool: {
- filter: [...filter, { term: { [TRANSACTION_SAMPLED]: true } }],
- },
- },
- },
- });
-
- return maybe(response.hits.hits[0]?._source);
- };
-
- const getUnsampledTransaction = async () => {
- const response = await apmEventClient.search({
- terminateAfter: 1,
- apm: {
- events: [ProcessorEvent.transaction],
- },
- body: {
- size: 1,
- query: {
- bool: {
- filter: [...filter, { term: { [TRANSACTION_SAMPLED]: false } }],
- },
- },
- },
- });
-
- return maybe(response.hits.hits[0]?._source);
- };
-
- const [sampledTransaction, unsampledTransaction] = await Promise.all([
- getSampledTransaction(),
- getUnsampledTransaction(),
- ]);
-
- return sampledTransaction || unsampledTransaction;
-}
diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts
index 4f7f6320185bf9..0e066a1959c495 100644
--- a/x-pack/plugins/apm/server/routes/create_apm_api.ts
+++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts
@@ -23,7 +23,6 @@ import {
serviceAnnotationsCreateRoute,
serviceErrorGroupsRoute,
serviceThroughputRoute,
- serviceTransactionGroupsRoute,
} from './services';
import {
agentConfigurationRoute,
@@ -52,13 +51,13 @@ import {
correlationsForFailedTransactionsRoute,
} from './correlations';
import {
- transactionGroupsBreakdownRoute,
- transactionGroupsChartsRoute,
- transactionGroupsDistributionRoute,
+ transactionChartsBreakdownRoute,
+ transactionChartsRoute,
+ transactionChartsDistributionRoute,
+ transactionChartsErrorRateRoute,
transactionGroupsRoute,
- transactionSampleForGroupRoute,
- transactionGroupsErrorRateRoute,
-} from './transaction_groups';
+ transactionGroupsOverviewRoute,
+} from './transactions/transactions_routes';
import {
errorGroupsLocalFiltersRoute,
metricsLocalFiltersRoute,
@@ -122,7 +121,6 @@ const createApmApi = () => {
.add(serviceAnnotationsCreateRoute)
.add(serviceErrorGroupsRoute)
.add(serviceThroughputRoute)
- .add(serviceTransactionGroupsRoute)
// Agent configuration
.add(getSingleAgentConfigurationRoute)
@@ -152,13 +150,13 @@ const createApmApi = () => {
.add(tracesByIdRoute)
.add(rootTransactionByTraceIdRoute)
- // Transaction groups
- .add(transactionGroupsBreakdownRoute)
- .add(transactionGroupsChartsRoute)
- .add(transactionGroupsDistributionRoute)
+ // Transactions
+ .add(transactionChartsBreakdownRoute)
+ .add(transactionChartsRoute)
+ .add(transactionChartsDistributionRoute)
+ .add(transactionChartsErrorRateRoute)
.add(transactionGroupsRoute)
- .add(transactionSampleForGroupRoute)
- .add(transactionGroupsErrorRateRoute)
+ .add(transactionGroupsOverviewRoute)
// UI filters
.add(errorGroupsLocalFiltersRoute)
diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts
index 64864ec2258bae..c4bc70a92d9eed 100644
--- a/x-pack/plugins/apm/server/routes/errors.ts
+++ b/x-pack/plugins/apm/server/routes/errors.ts
@@ -7,7 +7,7 @@
import * as t from 'io-ts';
import { createRoute } from './create_route';
import { getErrorDistribution } from '../lib/errors/distribution/get_distribution';
-import { getErrorGroup } from '../lib/errors/get_error_group';
+import { getErrorGroupSample } from '../lib/errors/get_error_group_sample';
import { getErrorGroups } from '../lib/errors/get_error_groups';
import { setupRequest } from '../lib/helpers/setup_request';
import { uiFiltersRt, rangeRt } from './default_api_types';
@@ -56,7 +56,7 @@ export const errorGroupsRoute = createRoute({
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const { serviceName, groupId } = context.params.path;
- return getErrorGroup({ serviceName, groupId, setup });
+ return getErrorGroupSample({ serviceName, groupId, setup });
},
});
diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts
index 4c5738ecef5815..a82f1b64d55379 100644
--- a/x-pack/plugins/apm/server/routes/services.ts
+++ b/x-pack/plugins/apm/server/routes/services.ts
@@ -19,7 +19,6 @@ import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt';
import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions';
import { getServiceErrorGroups } from '../lib/services/get_service_error_groups';
import { toNumberRt } from '../../common/runtime_types/to_number_rt';
-import { getServiceTransactionGroups } from '../lib/services/get_service_transaction_groups';
import { getThroughput } from '../lib/services/get_throughput';
export const servicesRoute = createRoute({
@@ -276,52 +275,3 @@ export const serviceThroughputRoute = createRoute({
});
},
});
-
-export const serviceTransactionGroupsRoute = createRoute({
- endpoint: 'GET /api/apm/services/{serviceName}/overview_transaction_groups',
- params: t.type({
- path: t.type({ serviceName: t.string }),
- query: t.intersection([
- rangeRt,
- uiFiltersRt,
- t.type({
- size: toNumberRt,
- numBuckets: toNumberRt,
- pageIndex: toNumberRt,
- sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
- sortField: t.union([
- t.literal('latency'),
- t.literal('throughput'),
- t.literal('errorRate'),
- t.literal('impact'),
- ]),
- }),
- ]),
- }),
- options: {
- tags: ['access:apm'],
- },
- handler: async ({ context, request }) => {
- const setup = await setupRequest(context, request);
-
- const searchAggregatedTransactions = await getSearchAggregatedTransactions(
- setup
- );
-
- const {
- path: { serviceName },
- query: { size, numBuckets, pageIndex, sortDirection, sortField },
- } = context.params;
-
- return getServiceTransactionGroups({
- setup,
- serviceName,
- pageIndex,
- searchAggregatedTransactions,
- size,
- sortDirection,
- sortField,
- numBuckets,
- });
- },
-});
diff --git a/x-pack/plugins/apm/server/routes/transaction_groups.ts b/x-pack/plugins/apm/server/routes/transactions/transactions_routes.ts
similarity index 62%
rename from x-pack/plugins/apm/server/routes/transaction_groups.ts
rename to x-pack/plugins/apm/server/routes/transactions/transactions_routes.ts
index 58c1ce3451a29f..11d247ccab84ff 100644
--- a/x-pack/plugins/apm/server/routes/transaction_groups.ts
+++ b/x-pack/plugins/apm/server/routes/transactions/transactions_routes.ts
@@ -4,21 +4,26 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import * as t from 'io-ts';
import Boom from '@hapi/boom';
-import { setupRequest } from '../lib/helpers/setup_request';
-import { getTransactionCharts } from '../lib/transactions/charts';
-import { getTransactionDistribution } from '../lib/transactions/distribution';
-import { getTransactionBreakdown } from '../lib/transactions/breakdown';
-import { getTransactionGroupList } from '../lib/transaction_groups';
-import { createRoute } from './create_route';
-import { uiFiltersRt, rangeRt } from './default_api_types';
-import { getTransactionSampleForGroup } from '../lib/transaction_groups/get_transaction_sample_for_group';
-import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions';
-import { getErrorRate } from '../lib/transaction_groups/get_error_rate';
+import * as t from 'io-ts';
+import { toNumberRt } from '../../../common/runtime_types/to_number_rt';
+import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions';
+import { setupRequest } from '../../lib/helpers/setup_request';
+import { getServiceTransactionGroups } from '../../lib/services/get_service_transaction_groups';
+import { getTransactionBreakdown } from '../../lib/transactions/breakdown';
+import { getTransactionCharts } from '../../lib/transactions/charts';
+import { getTransactionDistribution } from '../../lib/transactions/distribution';
+import { getTransactionGroupList } from '../../lib/transaction_groups';
+import { getErrorRate } from '../../lib/transaction_groups/get_error_rate';
+import { createRoute } from '../create_route';
+import { rangeRt, uiFiltersRt } from '../default_api_types';
+/**
+ * Returns a list of transactions grouped by name
+ * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/overview/
+ */
export const transactionGroupsRoute = createRoute({
- endpoint: 'GET /api/apm/services/{serviceName}/transaction_groups',
+ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups',
params: t.type({
path: t.type({
serviceName: t.string,
@@ -53,8 +58,64 @@ export const transactionGroupsRoute = createRoute({
},
});
-export const transactionGroupsChartsRoute = createRoute({
- endpoint: 'GET /api/apm/services/{serviceName}/transaction_groups/charts',
+export const transactionGroupsOverviewRoute = createRoute({
+ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups/overview',
+ params: t.type({
+ path: t.type({ serviceName: t.string }),
+ query: t.intersection([
+ rangeRt,
+ uiFiltersRt,
+ t.type({
+ size: toNumberRt,
+ numBuckets: toNumberRt,
+ pageIndex: toNumberRt,
+ sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
+ sortField: t.union([
+ t.literal('latency'),
+ t.literal('throughput'),
+ t.literal('errorRate'),
+ t.literal('impact'),
+ ]),
+ }),
+ ]),
+ }),
+ options: {
+ tags: ['access:apm'],
+ },
+ handler: async ({ context, request }) => {
+ const setup = await setupRequest(context, request);
+
+ const searchAggregatedTransactions = await getSearchAggregatedTransactions(
+ setup
+ );
+
+ const {
+ path: { serviceName },
+ query: { size, numBuckets, pageIndex, sortDirection, sortField },
+ } = context.params;
+
+ return getServiceTransactionGroups({
+ setup,
+ serviceName,
+ pageIndex,
+ searchAggregatedTransactions,
+ size,
+ sortDirection,
+ sortField,
+ numBuckets,
+ });
+ },
+});
+
+/**
+ * Returns timeseries for latency, throughput and anomalies
+ * TODO: break it into 3 new APIs:
+ * - Latency: /transactions/charts/latency
+ * - Throughput: /transactions/charts/throughput
+ * - anomalies: /transactions/charts/anomaly
+ */
+export const transactionChartsRoute = createRoute({
+ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts',
params: t.type({
path: t.type({
serviceName: t.string,
@@ -98,9 +159,9 @@ export const transactionGroupsChartsRoute = createRoute({
},
});
-export const transactionGroupsDistributionRoute = createRoute({
+export const transactionChartsDistributionRoute = createRoute({
endpoint:
- 'GET /api/apm/services/{serviceName}/transaction_groups/distribution',
+ 'GET /api/apm/services/{serviceName}/transactions/charts/distribution',
params: t.type({
path: t.type({
serviceName: t.string,
@@ -145,8 +206,8 @@ export const transactionGroupsDistributionRoute = createRoute({
},
});
-export const transactionGroupsBreakdownRoute = createRoute({
- endpoint: 'GET /api/apm/services/{serviceName}/transaction_groups/breakdown',
+export const transactionChartsBreakdownRoute = createRoute({
+ endpoint: 'GET /api/apm/services/{serviceName}/transaction/charts/breakdown',
params: t.type({
path: t.type({
serviceName: t.string,
@@ -177,33 +238,9 @@ export const transactionGroupsBreakdownRoute = createRoute({
},
});
-export const transactionSampleForGroupRoute = createRoute({
- endpoint: `GET /api/apm/transaction_sample`,
- params: t.type({
- query: t.intersection([
- uiFiltersRt,
- rangeRt,
- t.type({ serviceName: t.string, transactionName: t.string }),
- ]),
- }),
- options: { tags: ['access:apm'] },
- handler: async ({ context, request }) => {
- const setup = await setupRequest(context, request);
-
- const { transactionName, serviceName } = context.params.query;
-
- return {
- transaction: await getTransactionSampleForGroup({
- setup,
- serviceName,
- transactionName,
- }),
- };
- },
-});
-
-export const transactionGroupsErrorRateRoute = createRoute({
- endpoint: 'GET /api/apm/services/{serviceName}/transaction_groups/error_rate',
+export const transactionChartsErrorRateRoute = createRoute({
+ endpoint:
+ 'GET /api/apm/services/{serviceName}/transactions/charts/error_rate',
params: t.type({
path: t.type({
serviceName: t.string,
diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts
index 52e4a15a3f445f..9b99bf0e54cc2b 100644
--- a/x-pack/plugins/case/common/api/cases/case.ts
+++ b/x-pack/plugins/case/common/api/cases/case.ts
@@ -15,12 +15,24 @@ import { CaseConnectorRt, ESCaseConnector, ConnectorPartialFieldsRt } from '../c
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
export { ActionTypeExecutorResult } from '../../../../actions/server/types';
-const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]);
+export enum CaseStatuses {
+ open = 'open',
+ 'in-progress' = 'in-progress',
+ closed = 'closed',
+}
+
+const CaseStatusRt = rt.union([
+ rt.literal(CaseStatuses.open),
+ rt.literal(CaseStatuses['in-progress']),
+ rt.literal(CaseStatuses.closed),
+]);
+
+export const caseStatuses = Object.values(CaseStatuses);
const CaseBasicRt = rt.type({
connector: CaseConnectorRt,
description: rt.string,
- status: StatusRt,
+ status: CaseStatusRt,
tags: rt.array(rt.string),
title: rt.string,
});
@@ -68,7 +80,7 @@ export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt;
export const CasesFindRequestRt = rt.partial({
tags: rt.union([rt.array(rt.string), rt.string]),
- status: StatusRt,
+ status: CaseStatusRt,
reporters: rt.union([rt.array(rt.string), rt.string]),
defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]),
fields: rt.array(rt.string),
@@ -177,7 +189,6 @@ export type CasesResponse = rt.TypeOf;
export type CasesFindResponse = rt.TypeOf;
export type CasePatchRequest = rt.TypeOf;
export type CasesPatchRequest = rt.TypeOf;
-export type Status = rt.TypeOf;
export type CaseExternalServiceRequest = rt.TypeOf;
export type ServiceConnectorCaseParams = rt.TypeOf;
export type ServiceConnectorCaseResponse = rt.TypeOf;
diff --git a/x-pack/plugins/case/common/api/cases/status.ts b/x-pack/plugins/case/common/api/cases/status.ts
index 984181da8cdeea..b812126dc1eab6 100644
--- a/x-pack/plugins/case/common/api/cases/status.ts
+++ b/x-pack/plugins/case/common/api/cases/status.ts
@@ -8,6 +8,7 @@ import * as rt from 'io-ts';
export const CasesStatusResponseRt = rt.type({
count_open_cases: rt.number,
+ count_in_progress_cases: rt.number,
count_closed_cases: rt.number,
});
diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts
index d82979de2cb446..e09ce226b3125d 100644
--- a/x-pack/plugins/case/server/client/cases/create.test.ts
+++ b/x-pack/plugins/case/server/client/cases/create.test.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { ConnectorTypes, CasePostRequest } from '../../../common/api';
+import { ConnectorTypes, CasePostRequest, CaseStatuses } from '../../../common/api';
import {
createMockSavedObjectsRepository,
@@ -60,7 +60,7 @@ describe('create', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
updated_at: null,
updated_by: null,
@@ -126,7 +126,7 @@ describe('create', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
updated_at: null,
updated_by: null,
@@ -169,7 +169,7 @@ describe('create', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
updated_at: null,
updated_by: null,
@@ -316,7 +316,7 @@ describe('create', () => {
title: 'a title',
description: 'This is a brand new case of a bad meanie defacing data',
tags: ['defacement'],
- status: 'closed',
+ status: CaseStatuses.closed,
connector: {
id: 'none',
name: 'none',
diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts
index 10eebd1210a9e6..ae701f16b2bcb6 100644
--- a/x-pack/plugins/case/server/client/cases/update.test.ts
+++ b/x-pack/plugins/case/server/client/cases/update.test.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { ConnectorTypes, CasesPatchRequest } from '../../../common/api';
+import { ConnectorTypes, CasesPatchRequest, CaseStatuses } from '../../../common/api';
import {
createMockSavedObjectsRepository,
mockCaseNoConnectorId,
@@ -27,7 +27,7 @@ describe('update', () => {
cases: [
{
id: 'mock-id-1',
- status: 'closed' as const,
+ status: CaseStatuses.closed,
version: 'WzAsMV0=',
},
],
@@ -56,7 +56,7 @@ describe('update', () => {
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
external_service: null,
- status: 'closed',
+ status: CaseStatuses.closed,
tags: ['defacement'],
title: 'Super Bad Security Issue',
totalComment: 0,
@@ -79,8 +79,8 @@ describe('update', () => {
username: 'awesome',
},
action_field: ['status'],
- new_value: 'closed',
- old_value: 'open',
+ new_value: CaseStatuses.closed,
+ old_value: CaseStatuses.open,
},
references: [
{
@@ -98,7 +98,7 @@ describe('update', () => {
cases: [
{
id: 'mock-id-1',
- status: 'open' as const,
+ status: CaseStatuses.open,
version: 'WzAsMV0=',
},
],
@@ -106,7 +106,10 @@ describe('update', () => {
const savedObjectsClient = createMockSavedObjectsRepository({
caseSavedObject: [
- { ...mockCases[0], attributes: { ...mockCases[0].attributes, status: 'closed' } },
+ {
+ ...mockCases[0],
+ attributes: { ...mockCases[0].attributes, status: CaseStatuses.closed },
+ },
...mockCases.slice(1),
],
});
@@ -130,7 +133,7 @@ describe('update', () => {
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
external_service: null,
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
title: 'Super Bad Security Issue',
totalComment: 0,
@@ -146,7 +149,7 @@ describe('update', () => {
cases: [
{
id: 'mock-no-connector_id',
- status: 'closed' as const,
+ status: CaseStatuses.closed,
version: 'WzAsMV0=',
},
],
@@ -177,7 +180,7 @@ describe('update', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'closed',
+ status: CaseStatuses.closed,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
@@ -231,7 +234,7 @@ describe('update', () => {
description: 'Oh no, a bad meanie going LOLBins all over the place!',
external_service: null,
title: 'Another bad one',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['LOLBins'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
@@ -314,7 +317,7 @@ describe('update', () => {
cases: [
{
id: 'mock-id-1',
- status: 'open' as const,
+ status: CaseStatuses.open,
version: 'WzAsMV0=',
},
],
diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts
index a754ce27c5e41b..406e43a74cccfc 100644
--- a/x-pack/plugins/case/server/client/cases/update.ts
+++ b/x-pack/plugins/case/server/client/cases/update.ts
@@ -19,6 +19,7 @@ import {
ESCasePatchRequest,
CasePatchRequest,
CasesResponse,
+ CaseStatuses,
} from '../../../common/api';
import { buildCaseUserActions } from '../../services/user_actions/helpers';
import {
@@ -98,12 +99,15 @@ export const update = ({
cases: updateFilterCases.map((thisCase) => {
const { id: caseId, version, ...updateCaseAttributes } = thisCase;
let closedInfo = {};
- if (updateCaseAttributes.status && updateCaseAttributes.status === 'closed') {
+ if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) {
closedInfo = {
closed_at: updatedDt,
closed_by: { email, full_name, username },
};
- } else if (updateCaseAttributes.status && updateCaseAttributes.status === 'open') {
+ } else if (
+ updateCaseAttributes.status &&
+ updateCaseAttributes.status === CaseStatuses.open
+ ) {
closedInfo = {
closed_at: null,
closed_by: null,
diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts
index 90bb1d604e7330..adf94661216cbc 100644
--- a/x-pack/plugins/case/server/connectors/case/index.test.ts
+++ b/x-pack/plugins/case/server/connectors/case/index.test.ts
@@ -9,7 +9,7 @@ import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { actionsMock } from '../../../../actions/server/mocks';
import { validateParams } from '../../../../actions/server/lib';
-import { ConnectorTypes, CommentType } from '../../../common/api';
+import { ConnectorTypes, CommentType, CaseStatuses } from '../../../common/api';
import {
createCaseServiceMock,
createConfigureServiceMock,
@@ -785,7 +785,7 @@ describe('case connector', () => {
tags: ['case', 'connector'],
description: 'Yo fields!!',
external_service: null,
- status: 'open' as const,
+ status: CaseStatuses.open,
updated_at: null,
updated_by: null,
version: 'WzksMV0=',
@@ -868,7 +868,7 @@ describe('case connector', () => {
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
external_service: null,
- status: 'open' as const,
+ status: CaseStatuses.open,
tags: ['defacement'],
title: 'Update title',
totalComment: 0,
@@ -937,7 +937,7 @@ describe('case connector', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'open' as const,
+ status: CaseStatuses.open,
tags: ['defacement'],
updated_at: null,
updated_by: null,
diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts
index 4c0b5887ca9988..95856dd75d0ae8 100644
--- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts
+++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts
@@ -11,6 +11,7 @@ import {
ESCaseAttributes,
ConnectorTypes,
CommentType,
+ CaseStatuses,
} from '../../../../common/api';
export const mockCases: Array> = [
@@ -35,7 +36,7 @@ export const mockCases: Array> = [
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
@@ -69,7 +70,7 @@ export const mockCases: Array> = [
description: 'Oh no, a bad meanie destroying data!',
external_service: null,
title: 'Damaging Data Destruction Detected',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['Data Destruction'],
updated_at: '2019-11-25T22:32:00.900Z',
updated_by: {
@@ -107,7 +108,7 @@ export const mockCases: Array> = [
description: 'Oh no, a bad meanie going LOLBins all over the place!',
external_service: null,
title: 'Another bad one',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['LOLBins'],
updated_at: '2019-11-25T22:32:17.947Z',
updated_by: {
@@ -148,7 +149,7 @@ export const mockCases: Array> = [
},
description: 'Oh no, a bad meanie going LOLBins all over the place!',
external_service: null,
- status: 'closed',
+ status: CaseStatuses.closed,
title: 'Another bad one',
tags: ['LOLBins'],
updated_at: '2019-11-25T22:32:17.947Z',
@@ -179,7 +180,7 @@ export const mockCaseNoConnectorId: SavedObject> = {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts
index b2ba8b2fcb33ab..dca94589bf72ad 100644
--- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts
@@ -38,6 +38,10 @@ describe('FIND all cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.cases).toHaveLength(4);
+ // mockSavedObjectsRepository do not support filters and returns all cases every time.
+ expect(response.payload.count_open_cases).toEqual(4);
+ expect(response.payload.count_closed_cases).toEqual(4);
+ expect(response.payload.count_in_progress_cases).toEqual(4);
});
it(`has proper connector id on cases with configured connector`, async () => {
diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts
index e70225456d5a83..b034e86b4f0d48 100644
--- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts
@@ -11,7 +11,13 @@ import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { isEmpty } from 'lodash';
-import { CasesFindResponseRt, CasesFindRequestRt, throwErrors } from '../../../../common/api';
+import {
+ CasesFindResponseRt,
+ CasesFindRequestRt,
+ throwErrors,
+ CaseStatuses,
+ caseStatuses,
+} from '../../../../common/api';
import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils';
import { RouteDeps, TotalCommentByCase } from '../types';
import { CASE_SAVED_OBJECT } from '../../../saved_object_types';
@@ -20,7 +26,7 @@ import { CASES_URL } from '../../../../common/constants';
const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string =>
filters?.filter((i) => i !== '').join(` ${operator} `);
-const getStatusFilter = (status: 'open' | 'closed', appendFilter?: string) =>
+const getStatusFilter = (status: CaseStatuses, appendFilter?: string) =>
`${CASE_SAVED_OBJECT}.attributes.status: ${status}${
!isEmpty(appendFilter) ? ` AND ${appendFilter}` : ''
}`;
@@ -75,30 +81,21 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }:
client,
};
- const argsOpenCases = {
+ const statusArgs = caseStatuses.map((caseStatus) => ({
client,
options: {
fields: [],
page: 1,
perPage: 1,
- filter: getStatusFilter('open', myFilters),
+ filter: getStatusFilter(caseStatus, myFilters),
},
- };
+ }));
- const argsClosedCases = {
- client,
- options: {
- fields: [],
- page: 1,
- perPage: 1,
- filter: getStatusFilter('closed', myFilters),
- },
- };
- const [cases, openCases, closesCases] = await Promise.all([
+ const [cases, openCases, inProgressCases, closedCases] = await Promise.all([
caseService.findCases(args),
- caseService.findCases(argsOpenCases),
- caseService.findCases(argsClosedCases),
+ ...statusArgs.map((arg) => caseService.findCases(arg)),
]);
+
const totalCommentsFindByCases = await Promise.all(
cases.saved_objects.map((c) =>
caseService.getAllCaseComments({
@@ -133,7 +130,8 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }:
transformCases(
cases,
openCases.total ?? 0,
- closesCases.total ?? 0,
+ inProgressCases.total ?? 0,
+ closedCases.total ?? 0,
totalCommentsByCases
)
),
diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts
index ea69ee77c58020..053f9ec18ab0f8 100644
--- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts
@@ -16,7 +16,7 @@ import {
} from '../__fixtures__';
import { initPatchCasesApi } from './patch_cases';
import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects';
-import { ConnectorTypes } from '../../../../common/api/connectors';
+import { ConnectorTypes, CaseStatuses } from '../../../../common/api';
describe('PATCH cases', () => {
let routeHandler: RequestHandler;
@@ -36,7 +36,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-1',
- status: 'closed',
+ status: CaseStatuses.closed,
version: 'WzAsMV0=',
},
],
@@ -67,7 +67,7 @@ describe('PATCH cases', () => {
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
external_service: null,
- status: 'closed',
+ status: CaseStatuses.closed,
tags: ['defacement'],
title: 'Super Bad Security Issue',
totalComment: 0,
@@ -86,7 +86,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-4',
- status: 'open',
+ status: CaseStatuses.open,
version: 'WzUsMV0=',
},
],
@@ -118,7 +118,7 @@ describe('PATCH cases', () => {
description: 'Oh no, a bad meanie going LOLBins all over the place!',
id: 'mock-id-4',
external_service: null,
- status: 'open',
+ status: CaseStatuses.open,
tags: ['LOLBins'],
title: 'Another bad one',
totalComment: 0,
@@ -129,6 +129,56 @@ describe('PATCH cases', () => {
]);
});
+ it(`Change case to in-progress`, async () => {
+ const request = httpServerMock.createKibanaRequest({
+ path: '/api/cases',
+ method: 'patch',
+ body: {
+ cases: [
+ {
+ id: 'mock-id-1',
+ status: CaseStatuses['in-progress'],
+ version: 'WzAsMV0=',
+ },
+ ],
+ },
+ });
+
+ const theContext = await createRouteContext(
+ createMockSavedObjectsRepository({
+ caseSavedObject: mockCases,
+ })
+ );
+
+ const response = await routeHandler(theContext, request, kibanaResponseFactory);
+ expect(response.status).toEqual(200);
+ expect(response.payload).toEqual([
+ {
+ closed_at: null,
+ closed_by: null,
+ comments: [],
+ connector: {
+ id: 'none',
+ name: 'none',
+ type: ConnectorTypes.none,
+ fields: null,
+ },
+ created_at: '2019-11-25T21:54:48.952Z',
+ created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' },
+ description: 'This is a brand new case of a bad meanie defacing data',
+ id: 'mock-id-1',
+ external_service: null,
+ status: CaseStatuses['in-progress'],
+ tags: ['defacement'],
+ title: 'Super Bad Security Issue',
+ totalComment: 0,
+ updated_at: '2019-11-25T21:54:48.952Z',
+ updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
+ version: 'WzE3LDFd',
+ },
+ ]);
+ });
+
it(`Patches a case without a connector.id`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
@@ -137,7 +187,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-no-connector_id',
- status: 'closed',
+ status: CaseStatuses.closed,
version: 'WzAsMV0=',
},
],
@@ -163,7 +213,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-3',
- status: 'closed',
+ status: CaseStatuses.closed,
version: 'WzUsMV0=',
},
],
@@ -225,7 +275,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-1',
- case: { status: 'closed' },
+ case: { status: CaseStatuses.closed },
version: 'badv=',
},
],
@@ -250,7 +300,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-1',
- case: { status: 'open' },
+ case: { status: CaseStatuses.open },
version: 'WzAsMV0=',
},
],
@@ -276,7 +326,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-does-not-exist',
- status: 'closed',
+ status: CaseStatuses.closed,
version: 'WzAsMV0=',
},
],
diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts
index 1e1b19baa1c479..508684b4228917 100644
--- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts
@@ -16,7 +16,7 @@ import {
import { initPostCaseApi } from './post_case';
import { CASES_URL } from '../../../../common/constants';
import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects';
-import { ConnectorTypes } from '../../../../common/api/connectors';
+import { ConnectorTypes, CaseStatuses } from '../../../../common/api';
describe('POST cases', () => {
let routeHandler: RequestHandler;
@@ -54,6 +54,7 @@ describe('POST cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.id).toEqual('mock-it');
+ expect(response.payload.status).toEqual('open');
expect(response.payload.created_by.username).toEqual('awesome');
expect(response.payload.connector).toEqual({
id: 'none',
@@ -104,7 +105,7 @@ describe('POST cases', () => {
body: {
description: 'This is a brand new case of a bad meanie defacing data',
title: 'Super Bad Security Issue',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
connector: null,
},
@@ -191,7 +192,7 @@ describe('POST cases', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
id: 'mock-it',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
title: 'Super Bad Security Issue',
totalComment: 0,
diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts
index 6ba2da111090f7..6a6b09dc3f87ab 100644
--- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts
@@ -18,7 +18,12 @@ import {
getCommentContextFromAttributes,
} from '../utils';
-import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../../../../common/api';
+import {
+ CaseExternalServiceRequestRt,
+ CaseResponseRt,
+ throwErrors,
+ CaseStatuses,
+} from '../../../../common/api';
import { buildCaseUserActionItem } from '../../../services/user_actions/helpers';
import { RouteDeps } from '../types';
import { CASE_DETAILS_URL } from '../../../../common/constants';
@@ -77,7 +82,7 @@ export function initPushCaseUserActionApi({
actionsClient.getAll(),
]);
- if (myCase.attributes.status === 'closed') {
+ if (myCase.attributes.status === CaseStatuses.closed) {
throw Boom.conflict(
`This case ${myCase.attributes.title} is closed. You can not pushed if the case is closed.`
);
@@ -117,7 +122,7 @@ export function initPushCaseUserActionApi({
...(myCaseConfigure.total > 0 &&
myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing'
? {
- status: 'closed',
+ status: CaseStatuses.closed,
closed_at: pushedDate,
closed_by: { email, full_name, username },
}
@@ -153,7 +158,7 @@ export function initPushCaseUserActionApi({
actionBy: { username, full_name, email },
caseId,
fields: ['status'],
- newValue: 'closed',
+ newValue: CaseStatuses.closed,
oldValue: myCase.attributes.status,
}),
]
diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts
index 8f86dbc91f315d..4379a6b56367c0 100644
--- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts
@@ -7,7 +7,7 @@
import { RouteDeps } from '../../types';
import { wrapError } from '../../utils';
-import { CasesStatusResponseRt } from '../../../../../common/api';
+import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api';
import { CASE_SAVED_OBJECT } from '../../../../saved_object_types';
import { CASE_STATUS_URL } from '../../../../../common/constants';
@@ -20,34 +20,24 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) {
async (context, request, response) => {
try {
const client = context.core.savedObjects.client;
- const argsOpenCases = {
+ const args = caseStatuses.map((status) => ({
client,
options: {
fields: [],
page: 1,
perPage: 1,
- filter: `${CASE_SAVED_OBJECT}.attributes.status: open`,
+ filter: `${CASE_SAVED_OBJECT}.attributes.status: ${status}`,
},
- };
+ }));
- const argsClosedCases = {
- client,
- options: {
- fields: [],
- page: 1,
- perPage: 1,
- filter: `${CASE_SAVED_OBJECT}.attributes.status: closed`,
- },
- };
-
- const [openCases, closesCases] = await Promise.all([
- caseService.findCases(argsOpenCases),
- caseService.findCases(argsClosedCases),
- ]);
+ const [openCases, inProgressCases, closesCases] = await Promise.all(
+ args.map((arg) => caseService.findCases(arg))
+ );
return response.ok({
body: CasesStatusResponseRt.encode({
count_open_cases: openCases.total,
+ count_in_progress_cases: inProgressCases.total,
count_closed_cases: closesCases.total,
}),
});
diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts
index a67bae5ed74dc9..7654ae5ff0d1af 100644
--- a/x-pack/plugins/case/server/routes/api/utils.test.ts
+++ b/x-pack/plugins/case/server/routes/api/utils.test.ts
@@ -23,7 +23,7 @@ import {
mockCaseComments,
mockCaseNoConnectorId,
} from './__fixtures__/mock_saved_objects';
-import { ConnectorTypes, ESCaseConnector, CommentType } from '../../../common/api';
+import { ConnectorTypes, ESCaseConnector, CommentType, CaseStatuses } from '../../../common/api';
describe('Utils', () => {
describe('transformNewCase', () => {
@@ -57,7 +57,7 @@ describe('Utils', () => {
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' },
external_service: null,
- status: 'open',
+ status: CaseStatuses.open,
updated_at: null,
updated_by: null,
});
@@ -80,7 +80,7 @@ describe('Utils', () => {
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: undefined, full_name: undefined, username: undefined },
external_service: null,
- status: 'open',
+ status: CaseStatuses.open,
updated_at: null,
updated_by: null,
});
@@ -106,7 +106,7 @@ describe('Utils', () => {
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: null, full_name: null, username: null },
external_service: null,
- status: 'open',
+ status: CaseStatuses.open,
updated_at: null,
updated_by: null,
});
@@ -247,6 +247,7 @@ describe('Utils', () => {
},
2,
2,
+ 2,
extraCaseData
);
expect(res).toEqual({
@@ -259,6 +260,7 @@ describe('Utils', () => {
),
count_open_cases: 2,
count_closed_cases: 2,
+ count_in_progress_cases: 2,
});
});
});
@@ -289,7 +291,7 @@ describe('Utils', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
@@ -328,7 +330,7 @@ describe('Utils', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
@@ -374,7 +376,7 @@ describe('Utils', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
@@ -484,7 +486,7 @@ describe('Utils', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
- status: 'open',
+ status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts
index 589d7c02a7be60..c8753772648c29 100644
--- a/x-pack/plugins/case/server/routes/api/utils.ts
+++ b/x-pack/plugins/case/server/routes/api/utils.ts
@@ -33,6 +33,7 @@ import {
CommentType,
excess,
throwErrors,
+ CaseStatuses,
} from '../../../common/api';
import { transformESConnectorToCaseConnector } from './cases/helpers';
@@ -61,7 +62,7 @@ export const transformNewCase = ({
created_at: createdDate,
created_by: { email, full_name, username },
external_service: null,
- status: 'open',
+ status: CaseStatuses.open,
updated_at: null,
updated_by: null,
});
@@ -103,6 +104,7 @@ export function wrapError(error: any): CustomHttpResponseOptions
export const transformCases = (
cases: SavedObjectsFindResponse,
countOpenCases: number,
+ countInProgressCases: number,
countClosedCases: number,
totalCommentByCase: TotalCommentByCase[]
): CasesFindResponse => ({
@@ -111,6 +113,7 @@ export const transformCases = (
total: cases.total,
cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase),
count_open_cases: countOpenCases,
+ count_in_progress_cases: countInProgressCases,
count_closed_cases: countClosedCases,
});
diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts
index f1e06a0cec03df..f528843cf9ea36 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts
@@ -113,18 +113,3 @@ it('correctly determines attribute properties', () => {
}
}
});
-
-it('it correctly sets allowPredefinedID', () => {
- const defaultTypeDefinition = new EncryptedSavedObjectAttributesDefinition({
- type: 'so-type',
- attributesToEncrypt: new Set(['attr#1', 'attr#2']),
- });
- expect(defaultTypeDefinition.allowPredefinedID).toBe(false);
-
- const typeDefinitionWithPredefinedIDAllowed = new EncryptedSavedObjectAttributesDefinition({
- type: 'so-type',
- attributesToEncrypt: new Set(['attr#1', 'attr#2']),
- allowPredefinedID: true,
- });
- expect(typeDefinitionWithPredefinedIDAllowed.allowPredefinedID).toBe(true);
-});
diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts
index 398a64585411a7..849a2888b6e1a5 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts
@@ -15,7 +15,6 @@ export class EncryptedSavedObjectAttributesDefinition {
public readonly attributesToEncrypt: ReadonlySet;
private readonly attributesToExcludeFromAAD: ReadonlySet | undefined;
private readonly attributesToStrip: ReadonlySet;
- public readonly allowPredefinedID: boolean;
constructor(typeRegistration: EncryptedSavedObjectTypeRegistration) {
const attributesToEncrypt = new Set();
@@ -35,7 +34,6 @@ export class EncryptedSavedObjectAttributesDefinition {
this.attributesToEncrypt = attributesToEncrypt;
this.attributesToStrip = attributesToStrip;
this.attributesToExcludeFromAAD = typeRegistration.attributesToExcludeFromAAD;
- this.allowPredefinedID = !!typeRegistration.allowPredefinedID;
}
/**
diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts
index 0138e929ca1caf..c692d8698771fe 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts
@@ -13,7 +13,6 @@ import {
function createEncryptedSavedObjectsServiceMock() {
return ({
isRegistered: jest.fn(),
- canSpecifyID: jest.fn(),
stripOrDecryptAttributes: jest.fn(),
encryptAttributes: jest.fn(),
decryptAttributes: jest.fn(),
@@ -53,12 +52,6 @@ export const encryptedSavedObjectsServiceMock = {
mock.isRegistered.mockImplementation(
(type) => registrations.findIndex((r) => r.type === type) >= 0
);
- mock.canSpecifyID.mockImplementation((type, version, overwrite) => {
- const registration = registrations.find((r) => r.type === type);
- return (
- registration === undefined || registration.allowPredefinedID || !!(version && overwrite)
- );
- });
mock.encryptAttributes.mockImplementation(async (descriptor, attrs) =>
processAttributes(
descriptor,
diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts
index 6bc4a392064e43..88d57072697fe6 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts
@@ -89,45 +89,6 @@ describe('#isRegistered', () => {
});
});
-describe('#canSpecifyID', () => {
- it('returns true for unknown types', () => {
- expect(service.canSpecifyID('unknown-type')).toBe(true);
- });
-
- it('returns true for types registered setting allowPredefinedID to true', () => {
- service.registerType({
- type: 'known-type-1',
- attributesToEncrypt: new Set(['attr-1']),
- allowPredefinedID: true,
- });
- expect(service.canSpecifyID('known-type-1')).toBe(true);
- });
-
- it('returns true when overwriting a saved object with a version specified even when allowPredefinedID is not set', () => {
- service.registerType({
- type: 'known-type-1',
- attributesToEncrypt: new Set(['attr-1']),
- });
- expect(service.canSpecifyID('known-type-1', '2', true)).toBe(true);
- expect(service.canSpecifyID('known-type-1', '2', false)).toBe(false);
- expect(service.canSpecifyID('known-type-1', undefined, true)).toBe(false);
- });
-
- it('returns false for types registered without setting allowPredefinedID', () => {
- service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attr-1']) });
- expect(service.canSpecifyID('known-type-1')).toBe(false);
- });
-
- it('returns false for types registered setting allowPredefinedID to false', () => {
- service.registerType({
- type: 'known-type-1',
- attributesToEncrypt: new Set(['attr-1']),
- allowPredefinedID: false,
- });
- expect(service.canSpecifyID('known-type-1')).toBe(false);
- });
-});
-
describe('#stripOrDecryptAttributes', () => {
it('does not strip attributes from unknown types', async () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts
index 8d2ebb575c35e0..1f1093a179538c 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts
@@ -31,7 +31,6 @@ export interface EncryptedSavedObjectTypeRegistration {
readonly type: string;
readonly attributesToEncrypt: ReadonlySet;
readonly attributesToExcludeFromAAD?: ReadonlySet;
- readonly allowPredefinedID?: boolean;
}
/**
@@ -145,25 +144,6 @@ export class EncryptedSavedObjectsService {
return this.typeDefinitions.has(type);
}
- /**
- * Checks whether ID can be specified for the provided saved object.
- *
- * If the type isn't registered as an encrypted saved object, or when overwriting an existing
- * saved object with a version specified, this will return "true".
- *
- * @param type Saved object type.
- * @param version Saved object version number which changes on each successful write operation.
- * Can be used in conjunction with `overwrite` for implementing optimistic concurrency
- * control.
- * @param overwrite Overwrite existing documents.
- */
- public canSpecifyID(type: string, version?: string, overwrite?: boolean) {
- const typeDefinition = this.typeDefinitions.get(type);
- return (
- typeDefinition === undefined || typeDefinition.allowPredefinedID || !!(version && overwrite)
- );
- }
-
/**
* Takes saved object attributes for the specified type and, depending on the type definition,
* either decrypts or strips encrypted attributes (e.g. in case AAD or encryption key has changed
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts
index 3c722ccfabae24..85ec08fb7388d0 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts
@@ -13,7 +13,18 @@ import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from 'src/core/s
import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock';
import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock';
-jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('uuid-v4-id') }));
+jest.mock('../../../../../src/core/server/saved_objects/service/lib/utils', () => {
+ const { SavedObjectsUtils } = jest.requireActual(
+ '../../../../../src/core/server/saved_objects/service/lib/utils'
+ );
+ return {
+ SavedObjectsUtils: {
+ namespaceStringToId: SavedObjectsUtils.namespaceStringToId,
+ isRandomId: SavedObjectsUtils.isRandomId,
+ generateId: () => 'mock-saved-object-id',
+ },
+ };
+});
let wrapper: EncryptedSavedObjectsClientWrapper;
let mockBaseClient: jest.Mocked;
@@ -30,11 +41,6 @@ beforeEach(() => {
{ key: 'attrNotSoSecret', dangerouslyExposeValue: true },
]),
},
- {
- type: 'known-type-predefined-id',
- attributesToEncrypt: new Set(['attrSecret']),
- allowPredefinedID: true,
- },
]);
wrapper = new EncryptedSavedObjectsClientWrapper({
@@ -77,36 +83,16 @@ describe('#create', () => {
expect(mockBaseClient.create).toHaveBeenCalledWith('unknown-type', attributes, options);
});
- it('fails if type is registered without allowPredefinedID and ID is specified', async () => {
+ it('fails if type is registered and ID is specified', async () => {
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
await expect(wrapper.create('known-type', attributes, { id: 'some-id' })).rejects.toThrowError(
- 'Predefined IDs are not allowed for encrypted saved objects of type "known-type".'
+ 'Predefined IDs are not allowed for saved objects with encrypted attributes, unless the ID has been generated using `SavedObjectsUtils.generateId`.'
);
expect(mockBaseClient.create).not.toHaveBeenCalled();
});
- it('succeeds if type is registered with allowPredefinedID and ID is specified', async () => {
- const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
- const mockedResponse = {
- id: 'some-id',
- type: 'known-type-predefined-id',
- attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
- references: [],
- };
-
- mockBaseClient.create.mockResolvedValue(mockedResponse);
- await expect(
- wrapper.create('known-type-predefined-id', attributes, { id: 'some-id' })
- ).resolves.toEqual({
- ...mockedResponse,
- attributes: { attrOne: 'one', attrThree: 'three' },
- });
-
- expect(mockBaseClient.create).toHaveBeenCalled();
- });
-
it('allows a specified ID when overwriting an existing object', async () => {
const attributes = {
attrOne: 'one',
@@ -168,7 +154,7 @@ describe('#create', () => {
};
const options = { overwrite: true };
const mockedResponse = {
- id: 'uuid-v4-id',
+ id: 'mock-saved-object-id',
type: 'known-type',
attributes: {
attrOne: 'one',
@@ -188,7 +174,7 @@ describe('#create', () => {
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1);
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith(
- { type: 'known-type', id: 'uuid-v4-id' },
+ { type: 'known-type', id: 'mock-saved-object-id' },
{
attrOne: 'one',
attrSecret: 'secret',
@@ -207,7 +193,7 @@ describe('#create', () => {
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
- { id: 'uuid-v4-id', overwrite: true }
+ { id: 'mock-saved-object-id', overwrite: true }
);
});
@@ -216,7 +202,7 @@ describe('#create', () => {
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
const options = { overwrite: true, namespace };
const mockedResponse = {
- id: 'uuid-v4-id',
+ id: 'mock-saved-object-id',
type: 'known-type',
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
references: [],
@@ -233,7 +219,7 @@ describe('#create', () => {
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith(
{
type: 'known-type',
- id: 'uuid-v4-id',
+ id: 'mock-saved-object-id',
namespace: expectNamespaceInDescriptor ? namespace : undefined,
},
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
@@ -244,7 +230,7 @@ describe('#create', () => {
expect(mockBaseClient.create).toHaveBeenCalledWith(
'known-type',
{ attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
- { id: 'uuid-v4-id', overwrite: true, namespace }
+ { id: 'mock-saved-object-id', overwrite: true, namespace }
);
};
@@ -270,7 +256,7 @@ describe('#create', () => {
expect(mockBaseClient.create).toHaveBeenCalledWith(
'known-type',
{ attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
- { id: 'uuid-v4-id' }
+ { id: 'mock-saved-object-id' }
);
});
});
@@ -282,7 +268,7 @@ describe('#bulkCreate', () => {
const mockedResponse = {
saved_objects: [
{
- id: 'uuid-v4-id',
+ id: 'mock-saved-object-id',
type: 'known-type',
attributes,
references: [],
@@ -315,7 +301,7 @@ describe('#bulkCreate', () => {
[
{
...bulkCreateParams[0],
- id: 'uuid-v4-id',
+ id: 'mock-saved-object-id',
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
},
bulkCreateParams[1],
@@ -324,7 +310,7 @@ describe('#bulkCreate', () => {
);
});
- it('fails if ID is specified for registered type without allowPredefinedID', async () => {
+ it('fails if ID is specified for registered type', async () => {
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
const bulkCreateParams = [
@@ -333,48 +319,12 @@ describe('#bulkCreate', () => {
];
await expect(wrapper.bulkCreate(bulkCreateParams)).rejects.toThrowError(
- 'Predefined IDs are not allowed for encrypted saved objects of type "known-type".'
+ 'Predefined IDs are not allowed for saved objects with encrypted attributes, unless the ID has been generated using `SavedObjectsUtils.generateId`.'
);
expect(mockBaseClient.bulkCreate).not.toHaveBeenCalled();
});
- it('succeeds if ID is specified for registered type with allowPredefinedID', async () => {
- const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
- const options = { namespace: 'some-namespace' };
- const mockedResponse = {
- saved_objects: [
- {
- id: 'some-id',
- type: 'known-type-predefined-id',
- attributes,
- references: [],
- },
- {
- id: 'some-id',
- type: 'unknown-type',
- attributes,
- references: [],
- },
- ],
- };
- mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse);
-
- const bulkCreateParams = [
- { id: 'some-id', type: 'known-type-predefined-id', attributes },
- { type: 'unknown-type', attributes },
- ];
-
- await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({
- saved_objects: [
- { ...mockedResponse.saved_objects[0], attributes: { attrOne: 'one', attrThree: 'three' } },
- mockedResponse.saved_objects[1],
- ],
- });
-
- expect(mockBaseClient.bulkCreate).toHaveBeenCalled();
- });
-
it('allows a specified ID when overwriting an existing object', async () => {
const attributes = {
attrOne: 'one',
@@ -456,7 +406,7 @@ describe('#bulkCreate', () => {
const mockedResponse = {
saved_objects: [
{
- id: 'uuid-v4-id',
+ id: 'mock-saved-object-id',
type: 'known-type',
attributes: { ...attributes, attrSecret: '*secret*', attrNotSoSecret: '*not-so-secret*' },
references: [],
@@ -489,7 +439,7 @@ describe('#bulkCreate', () => {
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1);
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith(
- { type: 'known-type', id: 'uuid-v4-id' },
+ { type: 'known-type', id: 'mock-saved-object-id' },
{
attrOne: 'one',
attrSecret: 'secret',
@@ -504,7 +454,7 @@ describe('#bulkCreate', () => {
[
{
...bulkCreateParams[0],
- id: 'uuid-v4-id',
+ id: 'mock-saved-object-id',
attributes: {
attrOne: 'one',
attrSecret: '*secret*',
@@ -523,7 +473,9 @@ describe('#bulkCreate', () => {
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
const options = { namespace };
const mockedResponse = {
- saved_objects: [{ id: 'uuid-v4-id', type: 'known-type', attributes, references: [] }],
+ saved_objects: [
+ { id: 'mock-saved-object-id', type: 'known-type', attributes, references: [] },
+ ],
};
mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse);
@@ -542,7 +494,7 @@ describe('#bulkCreate', () => {
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith(
{
type: 'known-type',
- id: 'uuid-v4-id',
+ id: 'mock-saved-object-id',
namespace: expectNamespaceInDescriptor ? namespace : undefined,
},
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
@@ -554,7 +506,7 @@ describe('#bulkCreate', () => {
[
{
...bulkCreateParams[0],
- id: 'uuid-v4-id',
+ id: 'mock-saved-object-id',
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
},
],
@@ -590,7 +542,7 @@ describe('#bulkCreate', () => {
[
{
type: 'known-type',
- id: 'uuid-v4-id',
+ id: 'mock-saved-object-id',
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
},
],
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts
index ddef9f477433ca..313e7c7da9eba2 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts
@@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import uuid from 'uuid';
import {
SavedObject,
SavedObjectsBaseOptions,
@@ -25,7 +24,8 @@ import {
SavedObjectsRemoveReferencesToOptions,
ISavedObjectTypeRegistry,
SavedObjectsRemoveReferencesToResponse,
-} from 'src/core/server';
+ SavedObjectsUtils,
+} from '../../../../../src/core/server';
import { AuthenticatedUser } from '../../../security/common/model';
import { EncryptedSavedObjectsService } from '../crypto';
import { getDescriptorNamespace } from './get_descriptor_namespace';
@@ -37,14 +37,6 @@ interface EncryptedSavedObjectsClientOptions {
getCurrentUser: () => AuthenticatedUser | undefined;
}
-/**
- * Generates UUIDv4 ID for the any newly created saved object that is supposed to contain
- * encrypted attributes.
- */
-function generateID() {
- return uuid.v4();
-}
-
export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientContract {
constructor(
private readonly options: EncryptedSavedObjectsClientOptions,
@@ -67,19 +59,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return await this.options.baseClient.create(type, attributes, options);
}
- // Saved objects with encrypted attributes should have IDs that are hard to guess especially
- // since IDs are part of the AAD used during encryption. Types can opt-out of this restriction,
- // when necessary, but it's much safer for this wrapper to generate them.
- if (
- options.id &&
- !this.options.service.canSpecifyID(type, options.version, options.overwrite)
- ) {
- throw new Error(
- `Predefined IDs are not allowed for encrypted saved objects of type "${type}".`
- );
- }
-
- const id = options.id ?? generateID();
+ const id = getValidId(options.id, options.version, options.overwrite);
const namespace = getDescriptorNamespace(
this.options.baseTypeRegistry,
type,
@@ -113,19 +93,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return object;
}
- // Saved objects with encrypted attributes should have IDs that are hard to guess especially
- // since IDs are part of the AAD used during encryption, that's why we control them within this
- // wrapper and don't allow consumers to specify their own IDs directly unless overwriting the original document.
- if (
- object.id &&
- !this.options.service.canSpecifyID(object.type, object.version, options?.overwrite)
- ) {
- throw new Error(
- `Predefined IDs are not allowed for encrypted saved objects of type "${object.type}".`
- );
- }
-
- const id = object.id ?? generateID();
+ const id = getValidId(object.id, object.version, options?.overwrite);
const namespace = getDescriptorNamespace(
this.options.baseTypeRegistry,
object.type,
@@ -327,3 +295,26 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return response;
}
}
+
+// Saved objects with encrypted attributes should have IDs that are hard to guess especially
+// since IDs are part of the AAD used during encryption, that's why we control them within this
+// wrapper and don't allow consumers to specify their own IDs directly unless overwriting the original document.
+function getValidId(
+ id: string | undefined,
+ version: string | undefined,
+ overwrite: boolean | undefined
+) {
+ if (id) {
+ // only allow a specified ID if we're overwriting an existing ESO with a Version
+ // this helps us ensure that the document really was previously created using ESO
+ // and not being used to get around the specified ID limitation
+ const canSpecifyID = (overwrite && version) || SavedObjectsUtils.isRandomId(id);
+ if (!canSpecifyID) {
+ throw new Error(
+ 'Predefined IDs are not allowed for saved objects with encrypted attributes, unless the ID has been generated using `SavedObjectsUtils.generateId`.'
+ );
+ }
+ return id;
+ }
+ return SavedObjectsUtils.generateId();
+}
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx
index 0cad0b4d487d0e..f89b8b53a18786 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx
@@ -10,9 +10,11 @@ import {
EuiLink,
EuiAccordion,
EuiTitle,
+ EuiToolTip,
EuiPanel,
EuiButtonIcon,
EuiBasicTable,
+ EuiBasicTableProps,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
@@ -25,8 +27,15 @@ const StyledEuiAccordion = styled(EuiAccordion)`
.ingest-integration-title-button {
padding: ${(props) => props.theme.eui.paddingSizes.m}
${(props) => props.theme.eui.paddingSizes.m};
+ }
+
+ &.euiAccordion-isOpen .ingest-integration-title-button {
border-bottom: 1px solid ${(props) => props.theme.eui.euiColorLightShade};
}
+
+ .euiTableRow:last-child .euiTableRowCell {
+ border-bottom: none;
+ }
`;
const CollapsablePanel: React.FC<{ id: string; title: React.ReactNode }> = ({
@@ -35,7 +44,7 @@ const CollapsablePanel: React.FC<{ id: string; title: React.ReactNode }> = ({
children,
}) => {
return (
-
+
input.enabled);
}, [packagePolicy.inputs]);
- const columns = [
+ const columns: EuiBasicTableProps['columns'] = [
{
field: 'type',
width: '100%',
@@ -71,6 +80,7 @@ export const AgentDetailsIntegration: React.FunctionComponent<{
},
},
{
+ align: 'right',
name: i18n.translate('xpack.fleet.agentDetailsIntegrations.actionsLabel', {
defaultMessage: 'Actions',
}),
@@ -78,17 +88,20 @@ export const AgentDetailsIntegration: React.FunctionComponent<{
width: 'auto',
render: (inputType: string) => {
return (
-
+ >
+
+
);
},
},
@@ -142,7 +155,7 @@ export const AgentDetailsIntegrationsSection: React.FunctionComponent<{
}
return (
-
+
{(agentPolicy.package_policies as PackagePolicy[]).map((packagePolicy) => {
return (
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx
index a19f6658ef93fd..81195bdeaa9e26 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx
@@ -11,10 +11,10 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
+ EuiIcon,
+ EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { EuiText } from '@elastic/eui';
-import { EuiIcon } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { Agent, AgentPolicy } from '../../../../../types';
import { useKibanaVersion, useLink } from '../../../../../hooks';
@@ -66,14 +66,14 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{
{isAgentUpgradeable(agent, kibanaVersion) ? (
-
-
-
-
-
+
+
+
) : null}
@@ -81,12 +81,6 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{
'-'
),
},
- {
- title: i18n.translate('xpack.fleet.agentDetails.enrollmentTokenLabel', {
- defaultMessage: 'Enrollment token',
- }),
- description: '-', // Fixme when we have the enrollment tokenhttps://github.com/elastic/kibana/issues/61269
- },
{
title: i18n.translate('xpack.fleet.agentDetails.integrationsLabel', {
defaultMessage: 'Integrations',
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx
index be953ded70d799..b0e6ab751f02e1 100644
--- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx
+++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx
@@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EuiPortal, EuiTabs, EuiTab, EuiPanel, EuiTitle } from '@elastic/eui';
+import { EuiPortal, EuiTabs, EuiTab, EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
-import React, { CSSProperties, useMemo, useState } from 'react';
+import React, { useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui';
import { euiStyled } from '../../../../../../../observability/public';
import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib';
@@ -15,7 +15,7 @@ import { MetricsTab } from './tabs/metrics/metrics';
import { LogsTab } from './tabs/logs';
import { ProcessesTab } from './tabs/processes';
import { PropertiesTab } from './tabs/properties/index';
-import { OVERLAY_Y_START, OVERLAY_BOTTOM_MARGIN, OVERLAY_HEADER_SIZE } from './tabs/shared';
+import { OVERLAY_Y_START, OVERLAY_BOTTOM_MARGIN } from './tabs/shared';
import { useLinkProps } from '../../../../../hooks/use_link_props';
import { getNodeDetailUrl } from '../../../../link_to';
import { findInventoryModel } from '../../../../../../common/inventory_models';
@@ -70,21 +70,23 @@ export const NodeContextPopover = ({
return (
-
+
-
+
-
+
{node.name}
-
+
-
+
-
-
+
+
+
{tabs.map((tab, i) => (
setSelectedTab(i)}>
{tab.name}
@@ -112,32 +115,38 @@ export const NodeContextPopover = ({
{tabs[selectedTab].content}
-
+
);
};
const OverlayHeader = euiStyled.div`
- border-color: ${(props) => props.theme.eui.euiBorderColor};
- border-bottom-width: ${(props) => props.theme.eui.euiBorderWidthThick};
- padding-bottom: 0;
- overflow: hidden;
- background-color: ${(props) => props.theme.eui.euiColorLightestShade};
- height: ${OVERLAY_HEADER_SIZE}px;
+ padding-top: ${(props) => props.theme.eui.paddingSizes.m};
+ padding-right: ${(props) => props.theme.eui.paddingSizes.m};
+ padding-left: ${(props) => props.theme.eui.paddingSizes.m};
+ background-color: ${(props) => props.theme.eui.euiPageBackgroundColor};
+ box-shadow: inset 0 -1px ${(props) => props.theme.eui.euiBorderColor};
`;
-const OverlayHeaderTitleWrapper = euiStyled(EuiFlexGroup).attrs({ alignItems: 'center' })`
- padding: ${(props) => props.theme.eui.paddingSizes.s} ${(props) =>
- props.theme.eui.paddingSizes.m} 0;
-`;
+const OverlayPanel = euiStyled(EuiPanel).attrs({ paddingSize: 'none' })`
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ right: 16px;
+ top: ${OVERLAY_Y_START}px;
+ width: 100%;
+ max-width: 720px;
+ z-index: 2;
+ max-height: calc(100vh - ${OVERLAY_Y_START + OVERLAY_BOTTOM_MARGIN}px);
+ overflow: hidden;
-const panelStyle: CSSProperties = {
- position: 'absolute',
- right: 10,
- top: OVERLAY_Y_START,
- width: '50%',
- maxWidth: 730,
- zIndex: 2,
- height: `calc(100vh - ${OVERLAY_Y_START + OVERLAY_BOTTOM_MARGIN}px)`,
- overflow: 'hidden',
-};
+ @media (max-width: 752px) {
+ border-radius: 0px !important;
+ left: 0px;
+ right: 0px;
+ top: 97px;
+ bottom: 0;
+ max-height: calc(100vh - 97px);
+ max-width: 100%;
+ }
+`;
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx
index ce800a7d737001..81ca7d1dcd27f6 100644
--- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx
+++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx
@@ -15,7 +15,6 @@ import { TabContent, TabProps } from './shared';
import { LogStream } from '../../../../../../components/log_stream';
import { useWaffleOptionsContext } from '../../../hooks/use_waffle_options';
import { findInventoryFields } from '../../../../../../../common/inventory_models';
-import { euiStyled } from '../../../../../../../../observability/public';
import { useLinkProps } from '../../../../../../hooks/use_link_props';
import { getNodeLogsUrl } from '../../../../../link_to';
@@ -51,22 +50,25 @@ const TabComponent = (props: TabProps) => {
return (
-
+
-
-
-
+
-
+
{
);
};
-const QueryWrapper = euiStyled.div`
- padding: ${(props) => props.theme.eui.paddingSizes.m};
- padding-right: 0;
-`;
-
export const LogsTab = {
id: 'logs',
name: i18n.translate('xpack.infra.nodeDetails.tabs.logs', {
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx
index 63004072c08d05..ad4a48635d3765 100644
--- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx
+++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx
@@ -11,7 +11,6 @@ import { EuiFlexGroup } from '@elastic/eui';
import { EuiIcon } from '@elastic/eui';
import { colorTransformer } from '../../../../../../../../common/color_palette';
import { MetricsExplorerOptionsMetric } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options';
-import { euiStyled } from '../../../../../../../../../observability/public';
interface Props {
title: string;
@@ -20,33 +19,33 @@ interface Props {
export const ChartHeader = ({ title, metrics }: Props) => {
return (
-
+
-
- {title}
+
+ {title}
-
+
{metrics.map((chartMetric) => (
-
-
-
-
-
- {chartMetric.label}
-
-
+
+
+
+
+
+
+ {chartMetric.label}
+
+
+
))}
-
+
);
};
-
-const ChartHeaderWrapper = euiStyled.div`
- display: flex;
- width: 100%;
- padding: ${(props) => props.theme.eui.paddingSizes.s} ${(props) =>
- props.theme.eui.paddingSizes.m};
-`;
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx
index 789658c0604032..a295d8293632f4 100644
--- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx
+++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx
@@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n';
import {
Axis,
Chart,
+ ChartSizeArray,
niceTimeFormatter,
Position,
Settings,
@@ -17,7 +18,7 @@ import {
PointerEvent,
} from '@elastic/charts';
import moment from 'moment';
-import { EuiLoadingChart } from '@elastic/eui';
+import { EuiLoadingChart, EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui';
import { TabContent, TabProps } from '../shared';
import { useSnapshot } from '../../../../hooks/use_snaphot';
import { useWaffleOptionsContext } from '../../../../hooks/use_waffle_options';
@@ -39,7 +40,6 @@ import { createInventoryMetricFormatter } from '../../../../lib/create_inventory
import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain';
import { getTimelineChartTheme } from '../../../../../metrics_explorer/components/helpers/get_chart_theme';
import { useUiSetting } from '../../../../../../../../../../../src/plugins/kibana_react/public';
-import { euiStyled } from '../../../../../../../../../observability/public';
import { ChartHeader } from './chart_header';
import {
SYSTEM_METRIC_NAME,
@@ -56,6 +56,8 @@ import {
import { TimeDropdown } from './time_dropdown';
const ONE_HOUR = 60 * 60 * 1000;
+const CHART_SIZE: ChartSizeArray = ['100%', 160];
+
const TabComponent = (props: TabProps) => {
const cpuChartRef = useRef(null);
const networkChartRef = useRef(null);
@@ -282,217 +284,184 @@ const TabComponent = (props: TabProps) => {
return (
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
};
-const ChartsContainer = euiStyled.div`
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
-`;
-
-const ChartContainerWrapper = euiStyled.div`
- width: 50%
-`;
-
-const TimepickerWrapper = euiStyled.div`
- padding: ${(props) => props.theme.eui.paddingSizes.m};
- width: 50%;
-`;
-
-const ChartContainer: React.FC = ({ children }) => (
-
- {children}
-
-);
-
const LoadingPlaceholder = () => {
return (
(
{
};
const TableWrapper = euiStyled.div`
- margin-bottom: 20px
+ &:not(:last-child) {
+ margin-bottom: 16px
+ }
`;
const LoadingPlaceholder = () => {
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx
index c3e47b6084eb21..7f0ca2b6e262a3 100644
--- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx
+++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx
@@ -4,18 +4,20 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EuiText } from '@elastic/eui';
-import { EuiToolTip } from '@elastic/eui';
-import { EuiButtonIcon } from '@elastic/eui';
-import { EuiFlexGroup } from '@elastic/eui';
-import { EuiFlexItem } from '@elastic/eui';
-import { EuiLink } from '@elastic/eui';
-import { EuiBasicTable } from '@elastic/eui';
+import {
+ EuiText,
+ EuiToolTip,
+ EuiButtonIcon,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLink,
+ EuiBasicTable,
+ EuiSpacer,
+} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { first } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
-import { euiStyled } from '../../../../../../../../../observability/public';
interface Row {
name: string;
@@ -51,15 +53,19 @@ export const Table = (props: Props) => {
render: (_name: string, item: Row) => {
return (
-
-
-
+
+
+
{
)}
onClick={() => onClick(item)}
/>
-
-
- {!Array.isArray(item.value) && item.value}
- {Array.isArray(item.value) && }
-
-
-
+
+
+
+ {!Array.isArray(item.value) && item.value}
+ {Array.isArray(item.value) && }
+
+
);
},
@@ -86,20 +92,21 @@ export const Table = (props: Props) => {
return (
<>
-
-
- {title}
-
-
-
+
+ {title}
+
+
+
>
);
};
-const TitleWrapper = euiStyled.div`
- margin-bottom: 10px
-`;
-
class TableWithoutHeader extends EuiBasicTable {
renderTableHead() {
return <>>;
@@ -123,7 +130,7 @@ const ArrayValue = (props: MoreProps) => {
return (
<>
{!isExpanded && (
-
+
{first(values)}
{' ... '}
@@ -148,7 +155,7 @@ const ArrayValue = (props: MoreProps) => {
))}
{i18n.translate('xpack.infra.nodeDetails.tabs.metadata.seeLess', {
- defaultMessage: 'See less',
+ defaultMessage: 'Show less',
})}
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx
index 7386fa64aca9cf..6ff31e86c9d5e1 100644
--- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx
+++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx
@@ -17,11 +17,9 @@ export interface TabProps {
export const OVERLAY_Y_START = 266;
export const OVERLAY_BOTTOM_MARGIN = 16;
-export const OVERLAY_HEADER_SIZE = 96;
-const contentHeightOffset = OVERLAY_Y_START + OVERLAY_BOTTOM_MARGIN + OVERLAY_HEADER_SIZE;
export const TabContent = euiStyled.div`
- padding: ${(props) => props.theme.eui.paddingSizes.s};
- height: calc(100vh - ${contentHeightOffset}px);
+ padding: ${(props) => props.theme.eui.paddingSizes.m};
+ flex: 1;
overflow-y: auto;
overflow-x: hidden;
`;
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx
index 53d94f24d616ca..7402a712793faa 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx
@@ -1143,7 +1143,7 @@ describe('editor_frame', () => {
.find(EuiPanel)
.map((el) => el.parents(EuiToolTip).prop('content'))
).toEqual([
- 'Current',
+ 'Current visualization',
'Suggestion1',
'Suggestion2',
'Suggestion3',
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss
index 007d833e97e9d0..b3e6f68b0a68c8 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss
@@ -16,6 +16,7 @@
// Padding / negative margins to make room for overflow shadow
padding-left: $euiSizeXS;
margin-left: -$euiSizeXS;
+ padding-bottom: $euiSizeXS;
}
.lnsSuggestionPanel__button {
@@ -27,13 +28,31 @@
margin-left: $euiSizeXS / 2;
margin-bottom: $euiSizeXS / 2;
+ &:focus {
+ @include euiFocusRing;
+ transform: none !important; // sass-lint:disable-line no-important
+ }
+
.lnsSuggestionPanel__expressionRenderer {
position: static; // Let the progress indicator position itself against the button
}
}
.lnsSuggestionPanel__button-isSelected {
- @include euiFocusRing;
+ background-color: $euiColorLightestShade !important; // sass-lint:disable-line no-important
+ border-color: $euiColorMediumShade;
+
+ &:not(:focus) {
+ box-shadow: none !important; // sass-lint:disable-line no-important
+ }
+
+ &:focus {
+ @include euiFocusRing;
+ }
+
+ &:hover {
+ transform: none !important; // sass-lint:disable-line no-important
+ }
}
.lnsSuggestionPanel__suggestionIcon {
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx
index 382178a14793bf..9a1d7b23fa3dd3 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx
@@ -98,7 +98,7 @@ describe('suggestion_panel', () => {
.find('[data-test-subj="lnsSuggestion"]')
.find(EuiPanel)
.map((el) => el.parents(EuiToolTip).prop('content'))
- ).toEqual(['Current', 'Suggestion1', 'Suggestion2']);
+ ).toEqual(['Current visualization', 'Suggestion1', 'Suggestion2']);
});
describe('uncommitted suggestions', () => {
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx
index 913b3966225181..e42d4daffbb66d 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx
@@ -136,6 +136,8 @@ const SuggestionPreview = ({
paddingSize="none"
data-test-subj="lnsSuggestion"
onClick={onSelect}
+ aria-current={!!selected}
+ aria-label={preview.title}
>
{preview.expression || preview.error ? (
{
const example = {
type: 'lens',
+ id: 'mock-saved-object-id',
attributes: {
expression:
'kibana\n| kibana_context query="{\\"language\\":\\"kuery\\",\\"query\\":\\"\\"}" \n| lens_merge_tables layerIds="c61a8afb-a185-4fae-a064-fb3846f6c451" \n tables={esaggs index="logstash-*" metricsAtAllLevels=false partialRows=false includeFormatHints=true aggConfigs="[{\\"id\\":\\"2cd09808-3915-49f4-b3b0-82767eba23f7\\",\\"enabled\\":true,\\"type\\":\\"max\\",\\"schema\\":\\"metric\\",\\"params\\":{\\"field\\":\\"bytes\\"}}]" | lens_rename_columns idMap="{\\"col-0-2cd09808-3915-49f4-b3b0-82767eba23f7\\":\\"2cd09808-3915-49f4-b3b0-82767eba23f7\\"}"}\n| lens_metric_chart title="Maximum of bytes" accessor="2cd09808-3915-49f4-b3b0-82767eba23f7"',
@@ -164,6 +165,7 @@ describe('Lens migrations', () => {
const example = {
type: 'lens',
+ id: 'mock-saved-object-id',
attributes: {
expression: `kibana
| kibana_context query="{\\"query\\":\\"\\",\\"language\\":\\"kuery\\"}" filters="[]"
@@ -265,6 +267,7 @@ describe('Lens migrations', () => {
it('should handle pre-migrated expression', () => {
const input = {
type: 'lens',
+ id: 'mock-saved-object-id',
attributes: {
...example.attributes,
expression: `kibana
@@ -283,6 +286,7 @@ describe('Lens migrations', () => {
const context = {} as SavedObjectMigrationContext;
const example = {
+ id: 'mock-saved-object-id',
attributes: {
description: '',
expression:
@@ -513,6 +517,7 @@ describe('Lens migrations', () => {
const example = {
type: 'lens',
+ id: 'mock-saved-object-id',
attributes: {
state: {
datasourceStates: {
diff --git a/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts
index d52afebcaa2549..9ab965c3eb8fe1 100644
--- a/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts
+++ b/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts
@@ -254,3 +254,8 @@ export type DynamicStylePropertyOptions =
| LabelDynamicOptions
| OrientationDynamicOptions
| SizeDynamicOptions;
+
+export type DynamicStyleProperties = {
+ type: STYLE_TYPE.DYNAMIC;
+ options: DynamicStylePropertyOptions;
+};
diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts
index c8c9f6ba400410..f4cc1f3601f56c 100644
--- a/x-pack/plugins/maps/public/actions/layer_actions.ts
+++ b/x-pack/plugins/maps/public/actions/layer_actions.ts
@@ -45,6 +45,8 @@ import { IVectorLayer } from '../classes/layers/vector_layer/vector_layer';
import { LAYER_STYLE_TYPE, LAYER_TYPE } from '../../common/constants';
import { IVectorStyle } from '../classes/styles/vector/vector_style';
import { notifyLicensedFeatureUsage } from '../licensed_features';
+import { IESAggField } from '../classes/fields/agg';
+import { IField } from '../classes/fields/field';
export function trackCurrentLayerState(layerId: string) {
return {
@@ -274,6 +276,24 @@ export function updateLayerOrder(newLayerOrder: number[]) {
};
}
+function updateMetricsProp(layerId: string, value: unknown) {
+ return async (
+ dispatch: ThunkDispatch,
+ getState: () => MapStoreState
+ ) => {
+ const layer = getLayerById(layerId, getState());
+ const previousFields = await (layer as IVectorLayer).getFields();
+ await dispatch({
+ type: UPDATE_SOURCE_PROP,
+ layerId,
+ propName: 'metrics',
+ value,
+ });
+ await dispatch(updateStyleProperties(layerId, previousFields as IESAggField[]));
+ dispatch(syncDataForLayerId(layerId));
+ };
+}
+
export function updateSourceProp(
layerId: string,
propName: string,
@@ -281,6 +301,12 @@ export function updateSourceProp(
newLayerType?: LAYER_TYPE
) {
return async (dispatch: ThunkDispatch) => {
+ if (propName === 'metrics') {
+ if (newLayerType) {
+ throw new Error('May not change layer-type when modifying metrics source-property');
+ }
+ return await dispatch(updateMetricsProp(layerId, value));
+ }
dispatch({
type: UPDATE_SOURCE_PROP,
layerId,
@@ -290,7 +316,6 @@ export function updateSourceProp(
if (newLayerType) {
dispatch(updateLayerType(layerId, newLayerType));
}
- await dispatch(clearMissingStyleProperties(layerId));
dispatch(syncDataForLayerId(layerId));
};
}
@@ -422,7 +447,7 @@ function removeLayerFromLayerList(layerId: string) {
};
}
-export function clearMissingStyleProperties(layerId: string) {
+function updateStyleProperties(layerId: string, previousFields: IField[]) {
return async (
dispatch: ThunkDispatch,
getState: () => MapStoreState
@@ -441,8 +466,9 @@ export function clearMissingStyleProperties(layerId: string) {
const {
hasChanges,
nextStyleDescriptor,
- } = await (style as IVectorStyle).getDescriptorWithMissingStylePropsRemoved(
+ } = await (style as IVectorStyle).getDescriptorWithUpdatedStyleProps(
nextFields,
+ previousFields,
getMapColors(getState())
);
if (hasChanges && nextStyleDescriptor) {
@@ -485,13 +511,13 @@ export function updateLayerStyleForSelectedLayer(styleDescriptor: StyleDescripto
export function setJoinsForLayer(layer: ILayer, joins: JoinDescriptor[]) {
return async (dispatch: ThunkDispatch) => {
+ const previousFields = await (layer as IVectorLayer).getFields();
await dispatch({
type: SET_JOINS,
layer,
joins,
});
-
- await dispatch(clearMissingStyleProperties(layer.getId()));
+ await dispatch(updateStyleProperties(layer.getId(), previousFields));
dispatch(syncDataForLayerId(layer.getId()));
};
}
diff --git a/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts b/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts
index a4562c91e92a6c..ff6dbbce6f095a 100644
--- a/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts
+++ b/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts
@@ -97,4 +97,8 @@ export class CountAggField implements IESAggField {
canReadFromGeoJson(): boolean {
return this._canReadFromGeoJson;
}
+
+ isEqual(field: IESAggField) {
+ return field.getName() === this.getName();
+ }
}
diff --git a/x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts b/x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts
index e3d62afaca9218..cc8e3b46753080 100644
--- a/x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts
+++ b/x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts
@@ -83,4 +83,8 @@ export class TopTermPercentageField implements IESAggField {
canReadFromGeoJson(): boolean {
return this._canReadFromGeoJson;
}
+
+ isEqual(field: IESAggField) {
+ return field.getName() === this.getName();
+ }
}
diff --git a/x-pack/plugins/maps/public/classes/fields/field.ts b/x-pack/plugins/maps/public/classes/fields/field.ts
index 658c2bba878479..9cb7debd320a15 100644
--- a/x-pack/plugins/maps/public/classes/fields/field.ts
+++ b/x-pack/plugins/maps/public/classes/fields/field.ts
@@ -32,6 +32,7 @@ export interface IField {
supportsFieldMeta(): boolean;
canReadFromGeoJson(): boolean;
+ isEqual(field: IField): boolean;
}
export class AbstractField implements IField {
@@ -99,4 +100,8 @@ export class AbstractField implements IField {
canReadFromGeoJson(): boolean {
return true;
}
+
+ isEqual(field: IField) {
+ return this._origin === field.getOrigin() && this._fieldName === field.getName();
+ }
}
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.test.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.test.ts
new file mode 100644
index 00000000000000..9556862842e82f
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.test.ts
@@ -0,0 +1,117 @@
+/*
+ * 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 { FIELD_ORIGIN, VECTOR_STYLES } from '../../../../common/constants';
+import { createStyleFieldsHelper, StyleFieldsHelper } from './style_fields_helper';
+import { AbstractField, IField } from '../../fields/field';
+
+class MockField extends AbstractField {
+ private readonly _dataType: string;
+ private readonly _supportsAutoDomain: boolean;
+ constructor({ dataType, supportsAutoDomain }: { dataType: string; supportsAutoDomain: boolean }) {
+ super({ fieldName: 'foobar_' + dataType, origin: FIELD_ORIGIN.SOURCE });
+ this._dataType = dataType;
+ this._supportsAutoDomain = supportsAutoDomain;
+ }
+ async getDataType() {
+ return this._dataType;
+ }
+
+ supportsAutoDomain(): boolean {
+ return this._supportsAutoDomain;
+ }
+}
+
+describe('StyleFieldHelper', () => {
+ describe('isFieldDataTypeCompatibleWithStyleType', () => {
+ async function createHelper(
+ supportsAutoDomain: boolean
+ ): Promise<{
+ styleFieldHelper: StyleFieldsHelper;
+ stringField: IField;
+ numberField: IField;
+ dateField: IField;
+ }> {
+ const stringField = new MockField({
+ dataType: 'string',
+ supportsAutoDomain,
+ });
+ const numberField = new MockField({
+ dataType: 'number',
+ supportsAutoDomain,
+ });
+ const dateField = new MockField({
+ dataType: 'date',
+ supportsAutoDomain,
+ });
+ return {
+ styleFieldHelper: await createStyleFieldsHelper([stringField, numberField, dateField]),
+ stringField,
+ numberField,
+ dateField,
+ };
+ }
+
+ test('Should validate colors for all data types', async () => {
+ const { styleFieldHelper, stringField, numberField, dateField } = await createHelper(true);
+
+ [
+ VECTOR_STYLES.FILL_COLOR,
+ VECTOR_STYLES.LINE_COLOR,
+ VECTOR_STYLES.LABEL_COLOR,
+ VECTOR_STYLES.LABEL_BORDER_COLOR,
+ ].forEach((styleType) => {
+ expect(styleFieldHelper.hasFieldForStyle(stringField, styleType)).toEqual(true);
+ expect(styleFieldHelper.hasFieldForStyle(numberField, styleType)).toEqual(true);
+ expect(styleFieldHelper.hasFieldForStyle(dateField, styleType)).toEqual(true);
+ });
+ });
+
+ test('Should validate sizes for all number types', async () => {
+ const { styleFieldHelper, stringField, numberField, dateField } = await createHelper(true);
+
+ [VECTOR_STYLES.LINE_WIDTH, VECTOR_STYLES.LABEL_SIZE, VECTOR_STYLES.ICON_SIZE].forEach(
+ (styleType) => {
+ expect(styleFieldHelper.hasFieldForStyle(stringField, styleType)).toEqual(false);
+ expect(styleFieldHelper.hasFieldForStyle(numberField, styleType)).toEqual(true);
+ expect(styleFieldHelper.hasFieldForStyle(dateField, styleType)).toEqual(true);
+ }
+ );
+ });
+
+ test('Should not validate sizes if autodomain is not enabled', async () => {
+ const { styleFieldHelper, stringField, numberField, dateField } = await createHelper(false);
+
+ [VECTOR_STYLES.LINE_WIDTH, VECTOR_STYLES.LABEL_SIZE, VECTOR_STYLES.ICON_SIZE].forEach(
+ (styleType) => {
+ expect(styleFieldHelper.hasFieldForStyle(stringField, styleType)).toEqual(false);
+ expect(styleFieldHelper.hasFieldForStyle(numberField, styleType)).toEqual(false);
+ expect(styleFieldHelper.hasFieldForStyle(dateField, styleType)).toEqual(false);
+ }
+ );
+ });
+
+ test('Should validate orientation only number types', async () => {
+ const { styleFieldHelper, stringField, numberField, dateField } = await createHelper(true);
+
+ [VECTOR_STYLES.ICON_ORIENTATION].forEach((styleType) => {
+ expect(styleFieldHelper.hasFieldForStyle(stringField, styleType)).toEqual(false);
+ expect(styleFieldHelper.hasFieldForStyle(numberField, styleType)).toEqual(true);
+ expect(styleFieldHelper.hasFieldForStyle(dateField, styleType)).toEqual(false);
+ });
+ });
+
+ test('Should not validate label_border_size', async () => {
+ const { styleFieldHelper, stringField, numberField, dateField } = await createHelper(true);
+
+ [VECTOR_STYLES.LABEL_BORDER_SIZE].forEach((styleType) => {
+ expect(styleFieldHelper.hasFieldForStyle(stringField, styleType)).toEqual(false);
+ expect(styleFieldHelper.hasFieldForStyle(numberField, styleType)).toEqual(false);
+ expect(styleFieldHelper.hasFieldForStyle(dateField, styleType)).toEqual(false);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.ts
index fbe643a401484e..d36cf575a9bd8f 100644
--- a/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.ts
+++ b/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.ts
@@ -69,6 +69,11 @@ export class StyleFieldsHelper {
this._ordinalFields = ordinalFields;
}
+ hasFieldForStyle(field: IField, styleName: VECTOR_STYLES): boolean {
+ const fieldList = this.getFieldsForStyle(styleName);
+ return fieldList.some((styleField) => field.getName() === styleField.name);
+ }
+
getFieldsForStyle(styleName: VECTOR_STYLES): StyleField[] {
switch (styleName) {
case VECTOR_STYLES.ICON_ORIENTATION:
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js
index 1dbadc054c8a06..94090c8abfe4f8 100644
--- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js
+++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js
@@ -31,8 +31,8 @@ class MockSource {
}
}
-describe('getDescriptorWithMissingStylePropsRemoved', () => {
- const fieldName = 'doIStillExist';
+describe('getDescriptorWithUpdatedStyleProps', () => {
+ const previousFieldName = 'doIStillExist';
const mapColors = [];
const properties = {
fillColor: {
@@ -43,7 +43,7 @@ describe('getDescriptorWithMissingStylePropsRemoved', () => {
type: STYLE_TYPE.DYNAMIC,
options: {
field: {
- name: fieldName,
+ name: previousFieldName,
origin: FIELD_ORIGIN.SOURCE,
},
},
@@ -53,89 +53,123 @@ describe('getDescriptorWithMissingStylePropsRemoved', () => {
options: {
minSize: 1,
maxSize: 10,
- field: { name: fieldName, origin: FIELD_ORIGIN.SOURCE },
+ field: { name: previousFieldName, origin: FIELD_ORIGIN.SOURCE },
},
},
};
+ const previousFields = [new MockField({ fieldName: previousFieldName })];
+
beforeEach(() => {
require('../../../kibana_services').getUiSettings = () => ({
get: jest.fn(),
});
});
- it('Should return no changes when next ordinal fields contain existing style property fields', async () => {
- const vectorStyle = new VectorStyle({ properties }, new MockSource());
+ describe('When there is no mismatch in configuration', () => {
+ it('Should return no changes when next ordinal fields contain existing style property fields', async () => {
+ const vectorStyle = new VectorStyle({ properties }, new MockSource());
- const nextFields = [new MockField({ fieldName, dataType: 'number' })];
- const { hasChanges } = await vectorStyle.getDescriptorWithMissingStylePropsRemoved(
- nextFields,
- mapColors
- );
- expect(hasChanges).toBe(false);
+ const nextFields = [new MockField({ fieldName: previousFieldName, dataType: 'number' })];
+ const { hasChanges } = await vectorStyle.getDescriptorWithUpdatedStyleProps(
+ nextFields,
+ previousFields,
+ mapColors
+ );
+ expect(hasChanges).toBe(false);
+ });
});
- it('Should clear missing fields when next ordinal fields do not contain existing style property fields', async () => {
- const vectorStyle = new VectorStyle({ properties }, new MockSource());
+ describe('When styles should revert to static styling', () => {
+ it('Should convert dynamic styles to static styles when there are no next fields', async () => {
+ const vectorStyle = new VectorStyle({ properties }, new MockSource());
- const nextFields = [new MockField({ fieldName: 'someOtherField', dataType: 'number' })];
- const {
- hasChanges,
- nextStyleDescriptor,
- } = await vectorStyle.getDescriptorWithMissingStylePropsRemoved(nextFields, mapColors);
- expect(hasChanges).toBe(true);
- expect(nextStyleDescriptor.properties[VECTOR_STYLES.LINE_COLOR]).toEqual({
- options: {},
- type: 'DYNAMIC',
- });
- expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({
- options: {
- minSize: 1,
- maxSize: 10,
- },
- type: 'DYNAMIC',
+ const nextFields = [];
+ const {
+ hasChanges,
+ nextStyleDescriptor,
+ } = await vectorStyle.getDescriptorWithUpdatedStyleProps(
+ nextFields,
+ previousFields,
+ mapColors
+ );
+ expect(hasChanges).toBe(true);
+ expect(nextStyleDescriptor.properties[VECTOR_STYLES.LINE_COLOR]).toEqual({
+ options: {
+ color: '#41937c',
+ },
+ type: 'STATIC',
+ });
+ expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({
+ options: {
+ size: 6,
+ },
+ type: 'STATIC',
+ });
});
- });
- it('Should convert dynamic styles to static styles when there are no next fields', async () => {
- const vectorStyle = new VectorStyle({ properties }, new MockSource());
+ it('Should convert dynamic ICON_SIZE static style when there are no next ordinal fields', async () => {
+ const vectorStyle = new VectorStyle({ properties }, new MockSource());
- const nextFields = [];
- const {
- hasChanges,
- nextStyleDescriptor,
- } = await vectorStyle.getDescriptorWithMissingStylePropsRemoved(nextFields, mapColors);
- expect(hasChanges).toBe(true);
- expect(nextStyleDescriptor.properties[VECTOR_STYLES.LINE_COLOR]).toEqual({
- options: {
- color: '#41937c',
- },
- type: 'STATIC',
- });
- expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({
- options: {
- size: 6,
- },
- type: 'STATIC',
+ const nextFields = [
+ new MockField({
+ fieldName: previousFieldName,
+ dataType: 'number',
+ supportsAutoDomain: false,
+ }),
+ ];
+ const {
+ hasChanges,
+ nextStyleDescriptor,
+ } = await vectorStyle.getDescriptorWithUpdatedStyleProps(
+ nextFields,
+ previousFields,
+ mapColors
+ );
+ expect(hasChanges).toBe(true);
+ expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({
+ options: {
+ size: 6,
+ },
+ type: 'STATIC',
+ });
});
});
- it('Should convert dynamic ICON_SIZE static style when there are no next ordinal fields', async () => {
- const vectorStyle = new VectorStyle({ properties }, new MockSource());
+ describe('When styles should not be cleared', () => {
+ it('Should update field in styles when the fields and style combination remains compatible', async () => {
+ const vectorStyle = new VectorStyle({ properties }, new MockSource());
- const nextFields = [
- new MockField({ fieldName, dataType: 'number', supportsAutoDomain: false }),
- ];
- const {
- hasChanges,
- nextStyleDescriptor,
- } = await vectorStyle.getDescriptorWithMissingStylePropsRemoved(nextFields, mapColors);
- expect(hasChanges).toBe(true);
- expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({
- options: {
- size: 6,
- },
- type: 'STATIC',
+ const nextFields = [new MockField({ fieldName: 'someOtherField', dataType: 'number' })];
+ const {
+ hasChanges,
+ nextStyleDescriptor,
+ } = await vectorStyle.getDescriptorWithUpdatedStyleProps(
+ nextFields,
+ previousFields,
+ mapColors
+ );
+ expect(hasChanges).toBe(true);
+ expect(nextStyleDescriptor.properties[VECTOR_STYLES.LINE_COLOR]).toEqual({
+ options: {
+ field: {
+ name: 'someOtherField',
+ origin: FIELD_ORIGIN.SOURCE,
+ },
+ },
+ type: 'DYNAMIC',
+ });
+ expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({
+ options: {
+ minSize: 1,
+ maxSize: 10,
+ field: {
+ name: 'someOtherField',
+ origin: FIELD_ORIGIN.SOURCE,
+ },
+ },
+ type: 'DYNAMIC',
+ });
});
});
});
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx
index 2dc9ef612d8b24..1c36961aae1b1f 100644
--- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx
+++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx
@@ -6,17 +6,17 @@
import _ from 'lodash';
import React, { ReactElement } from 'react';
-import { Map as MbMap, FeatureIdentifier } from 'mapbox-gl';
+import { FeatureIdentifier, Map as MbMap } from 'mapbox-gl';
import { FeatureCollection } from 'geojson';
import { StyleProperties, VectorStyleEditor } from './components/vector_style_editor';
import { getDefaultStaticProperties, LINE_STYLES, POLYGON_STYLES } from './vector_style_defaults';
import {
- GEO_JSON_TYPE,
+ DEFAULT_ICON,
FIELD_ORIGIN,
- STYLE_TYPE,
- SOURCE_FORMATTERS_DATA_REQUEST_ID,
+ GEO_JSON_TYPE,
LAYER_STYLE_TYPE,
- DEFAULT_ICON,
+ SOURCE_FORMATTERS_DATA_REQUEST_ID,
+ STYLE_TYPE,
VECTOR_SHAPE_TYPE,
VECTOR_STYLES,
} from '../../../../common/constants';
@@ -25,7 +25,7 @@ import { VectorIcon } from './components/legend/vector_icon';
import { VectorStyleLegend } from './components/legend/vector_style_legend';
import { isOnlySingleFeatureType } from './style_util';
import { StaticStyleProperty } from './properties/static_style_property';
-import { DynamicStyleProperty } from './properties/dynamic_style_property';
+import { DynamicStyleProperty, IDynamicStyleProperty } from './properties/dynamic_style_property';
import { DynamicSizeProperty } from './properties/dynamic_size_property';
import { StaticSizeProperty } from './properties/static_size_property';
import { StaticColorProperty } from './properties/static_color_property';
@@ -43,6 +43,7 @@ import {
ColorDynamicOptions,
ColorStaticOptions,
ColorStylePropertyDescriptor,
+ DynamicStyleProperties,
DynamicStylePropertyOptions,
IconDynamicOptions,
IconStaticOptions,
@@ -66,11 +67,11 @@ import {
import { DataRequest } from '../../util/data_request';
import { IStyle } from '../style';
import { IStyleProperty } from './properties/style_property';
-import { IDynamicStyleProperty } from './properties/dynamic_style_property';
import { IField } from '../../fields/field';
import { IVectorLayer } from '../../layers/vector_layer/vector_layer';
import { IVectorSource } from '../../sources/vector_source';
-import { createStyleFieldsHelper } from './style_fields_helper';
+import { createStyleFieldsHelper, StyleFieldsHelper } from './style_fields_helper';
+import { IESAggField } from '../../fields/agg';
const POINTS = [GEO_JSON_TYPE.POINT, GEO_JSON_TYPE.MULTI_POINT];
const LINES = [GEO_JSON_TYPE.LINE_STRING, GEO_JSON_TYPE.MULTI_LINE_STRING];
@@ -81,8 +82,9 @@ export interface IVectorStyle extends IStyle {
getDynamicPropertiesArray(): Array>;
getSourceFieldNames(): string[];
getStyleMeta(): StyleMeta;
- getDescriptorWithMissingStylePropsRemoved(
+ getDescriptorWithUpdatedStyleProps(
nextFields: IField[],
+ previousFields: IField[],
mapColors: string[]
): Promise<{ hasChanges: boolean; nextStyleDescriptor?: VectorStyleDescriptor }>;
pluckStyleMetaFromSourceDataRequest(sourceDataRequest: DataRequest): Promise;
@@ -239,11 +241,187 @@ export class VectorStyle implements IVectorStyle {
);
}
+ async _updateFieldsInDescriptor(
+ nextFields: IField[],
+ styleFieldsHelper: StyleFieldsHelper,
+ previousFields: IField[],
+ mapColors: string[]
+ ) {
+ const originalProperties = this.getRawProperties();
+ const invalidStyleNames: VECTOR_STYLES[] = (Object.keys(
+ originalProperties
+ ) as VECTOR_STYLES[]).filter((key) => {
+ const dynamicOptions = getDynamicOptions(originalProperties, key);
+ if (!dynamicOptions || !dynamicOptions.field || !dynamicOptions.field.name) {
+ return false;
+ }
+
+ const hasMatchingField = nextFields.some((field) => {
+ return (
+ dynamicOptions && dynamicOptions.field && dynamicOptions.field.name === field.getName()
+ );
+ });
+ return !hasMatchingField;
+ });
+
+ let hasChanges = false;
+
+ const updatedProperties: VectorStylePropertiesDescriptor = { ...originalProperties };
+ invalidStyleNames.forEach((invalidStyleName) => {
+ for (let i = 0; i < previousFields.length; i++) {
+ const previousField = previousFields[i];
+ const nextField = nextFields[i];
+ if (previousField.isEqual(nextField)) {
+ continue;
+ }
+ const isFieldDataTypeCompatible = styleFieldsHelper.hasFieldForStyle(
+ nextField,
+ invalidStyleName
+ );
+ if (!isFieldDataTypeCompatible) {
+ return;
+ }
+ hasChanges = true;
+ (updatedProperties[invalidStyleName] as DynamicStyleProperties) = {
+ type: STYLE_TYPE.DYNAMIC,
+ options: {
+ ...originalProperties[invalidStyleName].options,
+ field: rectifyFieldDescriptor(nextField as IESAggField, {
+ origin: previousField.getOrigin(),
+ name: previousField.getName(),
+ }),
+ } as DynamicStylePropertyOptions,
+ };
+ }
+ });
+
+ return this._deleteFieldsFromDescriptorAndUpdateStyling(
+ nextFields,
+ updatedProperties,
+ hasChanges,
+ styleFieldsHelper,
+ mapColors
+ );
+ }
+
+ async _deleteFieldsFromDescriptorAndUpdateStyling(
+ nextFields: IField[],
+ originalProperties: VectorStylePropertiesDescriptor,
+ hasChanges: boolean,
+ styleFieldsHelper: StyleFieldsHelper,
+ mapColors: string[]
+ ) {
+ // const originalProperties = this.getRawProperties();
+ const updatedProperties = {} as VectorStylePropertiesDescriptor;
+
+ const dynamicProperties = (Object.keys(originalProperties) as VECTOR_STYLES[]).filter((key) => {
+ const dynamicOptions = getDynamicOptions(originalProperties, key);
+ return dynamicOptions && dynamicOptions.field && dynamicOptions.field.name;
+ });
+
+ dynamicProperties.forEach((key: VECTOR_STYLES) => {
+ // Convert dynamic styling to static stying when there are no style fields
+ const styleFields = styleFieldsHelper.getFieldsForStyle(key);
+ if (styleFields.length === 0) {
+ const staticProperties = getDefaultStaticProperties(mapColors);
+ updatedProperties[key] = staticProperties[key] as any;
+ return;
+ }
+
+ const dynamicProperty = originalProperties[key];
+ if (!dynamicProperty || !dynamicProperty.options) {
+ return;
+ }
+ const fieldName = (dynamicProperty.options as DynamicStylePropertyOptions).field!.name;
+ if (!fieldName) {
+ return;
+ }
+
+ const matchingOrdinalField = nextFields.find((ordinalField) => {
+ return fieldName === ordinalField.getName();
+ });
+
+ if (matchingOrdinalField) {
+ return;
+ }
+
+ updatedProperties[key] = {
+ type: DynamicStyleProperty.type,
+ options: {
+ ...originalProperties[key]!.options,
+ },
+ } as any;
+
+ if ('field' in updatedProperties[key].options) {
+ delete (updatedProperties[key].options as DynamicStylePropertyOptions).field;
+ }
+ });
+
+ if (Object.keys(updatedProperties).length !== 0) {
+ return {
+ hasChanges: true,
+ nextStyleDescriptor: VectorStyle.createDescriptor(
+ {
+ ...originalProperties,
+ ...updatedProperties,
+ },
+ this.isTimeAware()
+ ),
+ };
+ } else {
+ return {
+ hasChanges,
+ nextStyleDescriptor: VectorStyle.createDescriptor(
+ {
+ ...originalProperties,
+ },
+ this.isTimeAware()
+ ),
+ };
+ }
+ }
+
+ /*
+ * Changes to source descriptor and join descriptor will impact style properties.
+ * For instance, a style property may be dynamically tied to the value of an ordinal field defined
+ * by a join or a metric aggregation. The metric aggregation or join may be edited or removed.
+ * When this happens, the style will be linked to a no-longer-existing ordinal field.
+ * This method provides a way for a style to clean itself and return a descriptor that unsets any dynamic
+ * properties that are tied to missing oridinal fields
+ *
+ * This method does not update its descriptor. It just returns a new descriptor that the caller
+ * can then use to update store state via dispatch.
+ */
+ async getDescriptorWithUpdatedStyleProps(
+ nextFields: IField[],
+ previousFields: IField[],
+ mapColors: string[]
+ ) {
+ const styleFieldsHelper = await createStyleFieldsHelper(nextFields);
+
+ return previousFields.length === nextFields.length
+ ? // Field-config changed
+ await this._updateFieldsInDescriptor(
+ nextFields,
+ styleFieldsHelper,
+ previousFields,
+ mapColors
+ )
+ : // Deletions or additions
+ await this._deleteFieldsFromDescriptorAndUpdateStyling(
+ nextFields,
+ this.getRawProperties(),
+ false,
+ styleFieldsHelper,
+ mapColors
+ );
+ }
+
getType() {
return LAYER_STYLE_TYPE.VECTOR;
}
- getAllStyleProperties() {
+ getAllStyleProperties(): Array> {
return [
this._symbolizeAsStyleProperty,
this._iconStyleProperty,
@@ -303,94 +481,6 @@ export class VectorStyle implements IVectorStyle {
);
}
- /*
- * Changes to source descriptor and join descriptor will impact style properties.
- * For instance, a style property may be dynamically tied to the value of an ordinal field defined
- * by a join or a metric aggregation. The metric aggregation or join may be edited or removed.
- * When this happens, the style will be linked to a no-longer-existing ordinal field.
- * This method provides a way for a style to clean itself and return a descriptor that unsets any dynamic
- * properties that are tied to missing oridinal fields
- *
- * This method does not update its descriptor. It just returns a new descriptor that the caller
- * can then use to update store state via dispatch.
- */
- async getDescriptorWithMissingStylePropsRemoved(nextFields: IField[], mapColors: string[]) {
- const styleFieldsHelper = await createStyleFieldsHelper(nextFields);
- const originalProperties = this.getRawProperties();
- const updatedProperties = {} as VectorStylePropertiesDescriptor;
-
- const dynamicProperties = (Object.keys(originalProperties) as VECTOR_STYLES[]).filter((key) => {
- if (!originalProperties[key]) {
- return false;
- }
- const propertyDescriptor = originalProperties[key];
- if (
- !propertyDescriptor ||
- !('type' in propertyDescriptor) ||
- propertyDescriptor.type !== STYLE_TYPE.DYNAMIC ||
- !propertyDescriptor.options
- ) {
- return false;
- }
- const dynamicOptions = propertyDescriptor.options as DynamicStylePropertyOptions;
- return dynamicOptions.field && dynamicOptions.field.name;
- });
-
- dynamicProperties.forEach((key: VECTOR_STYLES) => {
- // Convert dynamic styling to static stying when there are no style fields
- const styleFields = styleFieldsHelper.getFieldsForStyle(key);
- if (styleFields.length === 0) {
- const staticProperties = getDefaultStaticProperties(mapColors);
- updatedProperties[key] = staticProperties[key] as any;
- return;
- }
-
- const dynamicProperty = originalProperties[key];
- if (!dynamicProperty || !dynamicProperty.options) {
- return;
- }
- const fieldName = (dynamicProperty.options as DynamicStylePropertyOptions).field!.name;
- if (!fieldName) {
- return;
- }
-
- const matchingOrdinalField = nextFields.find((ordinalField) => {
- return fieldName === ordinalField.getName();
- });
-
- if (matchingOrdinalField) {
- return;
- }
-
- updatedProperties[key] = {
- type: DynamicStyleProperty.type,
- options: {
- ...originalProperties[key].options,
- },
- } as any;
- // @ts-expect-error
- delete updatedProperties[key].options.field;
- });
-
- if (Object.keys(updatedProperties).length === 0) {
- return {
- hasChanges: false,
- nextStyleDescriptor: { ...this._descriptor },
- };
- }
-
- return {
- hasChanges: true,
- nextStyleDescriptor: VectorStyle.createDescriptor(
- {
- ...originalProperties,
- ...updatedProperties,
- },
- this.isTimeAware()
- ),
- };
- }
-
async pluckStyleMetaFromSourceDataRequest(sourceDataRequest: DataRequest) {
const features = _.get(sourceDataRequest.getData(), 'features', []);
@@ -478,11 +568,11 @@ export class VectorStyle implements IVectorStyle {
return this._descriptor.isTimeAware;
}
- getRawProperties() {
+ getRawProperties(): VectorStylePropertiesDescriptor {
return this._descriptor.properties || {};
}
- getDynamicPropertiesArray() {
+ getDynamicPropertiesArray(): Array> {
const styleProperties = this.getAllStyleProperties();
return styleProperties.filter(
(styleProperty) => styleProperty.isDynamic() && styleProperty.isComplete()
@@ -882,3 +972,32 @@ export class VectorStyle implements IVectorStyle {
}
}
}
+
+function getDynamicOptions(
+ originalProperties: VectorStylePropertiesDescriptor,
+ key: VECTOR_STYLES
+): DynamicStylePropertyOptions | null {
+ if (!originalProperties[key]) {
+ return null;
+ }
+ const propertyDescriptor = originalProperties[key];
+ if (
+ !propertyDescriptor ||
+ !('type' in propertyDescriptor) ||
+ propertyDescriptor.type !== STYLE_TYPE.DYNAMIC ||
+ !propertyDescriptor.options
+ ) {
+ return null;
+ }
+ return propertyDescriptor.options as DynamicStylePropertyOptions;
+}
+
+function rectifyFieldDescriptor(
+ currentField: IESAggField,
+ previousFieldDescriptor: StylePropertyField
+): StylePropertyField {
+ return {
+ origin: previousFieldDescriptor.origin,
+ name: currentField.getName(),
+ };
+}
diff --git a/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.js b/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.ts
similarity index 74%
rename from x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.js
rename to x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.ts
index 37e739d0066a00..fc103959381bcb 100644
--- a/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.js
+++ b/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.ts
@@ -4,9 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { get } from 'lodash';
+// @ts-ignore
import { createApmQuery } from './create_apm_query';
+// @ts-ignore
import { ApmClusterMetric } from '../metrics';
+import { LegacyRequest, ElasticsearchResponse } from '../../types';
export async function getTimeOfLastEvent({
req,
@@ -15,6 +17,13 @@ export async function getTimeOfLastEvent({
start,
end,
clusterUuid,
+}: {
+ req: LegacyRequest;
+ callWithRequest: (_req: any, endpoint: string, params: any) => Promise;
+ apmIndexPattern: string;
+ start: number;
+ end: number;
+ clusterUuid: string;
}) {
const params = {
index: apmIndexPattern,
@@ -49,5 +58,5 @@ export async function getTimeOfLastEvent({
};
const response = await callWithRequest(req, 'search', params);
- return get(response, 'hits.hits[0]._source.timestamp');
+ return response.hits?.hits.length ? response.hits?.hits[0]._source.timestamp : undefined;
}
diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.js b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts
similarity index 65%
rename from x-pack/plugins/monitoring/server/lib/apm/get_apm_info.js
rename to x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts
index ea37ff7783ad70..4ca708e9d28322 100644
--- a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.js
+++ b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts
@@ -4,39 +4,49 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { get, upperFirst } from 'lodash';
+import { upperFirst } from 'lodash';
+// @ts-ignore
import { checkParam } from '../error_missing_required';
+// @ts-ignore
import { createQuery } from '../create_query';
+// @ts-ignore
import { getDiffCalculation } from '../beats/_beats_stats';
+// @ts-ignore
import { ApmMetric } from '../metrics';
import { getTimeOfLastEvent } from './_get_time_of_last_event';
+import { LegacyRequest, ElasticsearchResponse } from '../../types';
-export function handleResponse(response, apmUuid) {
- const firstStats = get(
- response,
- 'hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats'
- );
- const stats = get(response, 'hits.hits[0]._source.beats_stats');
+export function handleResponse(response: ElasticsearchResponse, apmUuid: string) {
+ if (!response.hits || response.hits.hits.length === 0) {
+ return {};
+ }
- const eventsTotalFirst = get(firstStats, 'metrics.libbeat.pipeline.events.total', null);
- const eventsEmittedFirst = get(firstStats, 'metrics.libbeat.pipeline.events.published', null);
- const eventsDroppedFirst = get(firstStats, 'metrics.libbeat.pipeline.events.dropped', null);
- const bytesWrittenFirst = get(firstStats, 'metrics.libbeat.output.write.bytes', null);
+ const firstStats = response.hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats;
+ const stats = response.hits.hits[0]._source.beats_stats;
- const eventsTotalLast = get(stats, 'metrics.libbeat.pipeline.events.total', null);
- const eventsEmittedLast = get(stats, 'metrics.libbeat.pipeline.events.published', null);
- const eventsDroppedLast = get(stats, 'metrics.libbeat.pipeline.events.dropped', null);
- const bytesWrittenLast = get(stats, 'metrics.libbeat.output.write.bytes', null);
+ if (!firstStats || !stats) {
+ return {};
+ }
+
+ const eventsTotalFirst = firstStats.metrics?.libbeat?.pipeline?.events?.total;
+ const eventsEmittedFirst = firstStats.metrics?.libbeat?.pipeline?.events?.published;
+ const eventsDroppedFirst = firstStats.metrics?.libbeat?.pipeline?.events?.dropped;
+ const bytesWrittenFirst = firstStats.metrics?.libbeat?.output?.write?.bytes;
+
+ const eventsTotalLast = stats.metrics?.libbeat?.pipeline?.events?.total;
+ const eventsEmittedLast = stats.metrics?.libbeat?.pipeline?.events?.published;
+ const eventsDroppedLast = stats.metrics?.libbeat?.pipeline?.events?.dropped;
+ const bytesWrittenLast = stats.metrics?.libbeat?.output?.write?.bytes;
return {
uuid: apmUuid,
- transportAddress: get(stats, 'beat.host', null),
- version: get(stats, 'beat.version', null),
- name: get(stats, 'beat.name', null),
- type: upperFirst(get(stats, 'beat.type')) || null,
- output: upperFirst(get(stats, 'metrics.libbeat.output.type')) || null,
- configReloads: get(stats, 'metrics.libbeat.config.reloads', null),
- uptime: get(stats, 'metrics.beat.info.uptime.ms', null),
+ transportAddress: stats.beat?.host,
+ version: stats.beat?.version,
+ name: stats.beat?.name,
+ type: upperFirst(stats.beat?.type) || null,
+ output: upperFirst(stats.metrics?.libbeat?.output?.type) || null,
+ configReloads: stats.metrics?.libbeat?.config?.reloads,
+ uptime: stats.metrics?.beat?.info?.uptime?.ms,
eventsTotal: getDiffCalculation(eventsTotalLast, eventsTotalFirst),
eventsEmitted: getDiffCalculation(eventsEmittedLast, eventsEmittedFirst),
eventsDropped: getDiffCalculation(eventsDroppedLast, eventsDroppedFirst),
@@ -44,7 +54,21 @@ export function handleResponse(response, apmUuid) {
};
}
-export async function getApmInfo(req, apmIndexPattern, { clusterUuid, apmUuid, start, end }) {
+export async function getApmInfo(
+ req: LegacyRequest,
+ apmIndexPattern: string,
+ {
+ clusterUuid,
+ apmUuid,
+ start,
+ end,
+ }: {
+ clusterUuid: string;
+ apmUuid: string;
+ start: number;
+ end: number;
+ }
+) {
checkParam(apmIndexPattern, 'apmIndexPattern in beats/getBeatSummary');
const filters = [
diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apms.js b/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts
similarity index 69%
rename from x-pack/plugins/monitoring/server/lib/apm/get_apms.js
rename to x-pack/plugins/monitoring/server/lib/apm/get_apms.ts
index 2d59bfea72eb2b..f6df94f8de138b 100644
--- a/x-pack/plugins/monitoring/server/lib/apm/get_apms.js
+++ b/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts
@@ -5,68 +5,79 @@
*/
import moment from 'moment';
-import { upperFirst, get } from 'lodash';
+import { upperFirst } from 'lodash';
+// @ts-ignore
import { checkParam } from '../error_missing_required';
+// @ts-ignore
import { createApmQuery } from './create_apm_query';
+// @ts-ignore
import { calculateRate } from '../calculate_rate';
+// @ts-ignore
import { getDiffCalculation } from './_apm_stats';
+import { LegacyRequest, ElasticsearchResponse, ElasticsearchResponseHit } from '../../types';
-export function handleResponse(response, start, end) {
- const hits = get(response, 'hits.hits', []);
+export function handleResponse(response: ElasticsearchResponse, start: number, end: number) {
const initial = { ids: new Set(), beats: [] };
- const { beats } = hits.reduce((accum, hit) => {
- const stats = get(hit, '_source.beats_stats');
- const uuid = get(stats, 'beat.uuid');
+ const { beats } = response.hits?.hits.reduce((accum: any, hit: ElasticsearchResponseHit) => {
+ const stats = hit._source.beats_stats;
+ if (!stats) {
+ return accum;
+ }
+
+ const earliestStats = hit.inner_hits.earliest.hits.hits[0]._source.beats_stats;
+ if (!earliestStats) {
+ return accum;
+ }
+
+ const uuid = stats?.beat?.uuid;
// skip this duplicated beat, newer one was already added
if (accum.ids.has(uuid)) {
return accum;
}
-
// add another beat summary
accum.ids.add(uuid);
- const earliestStats = get(hit, 'inner_hits.earliest.hits.hits[0]._source.beats_stats');
// add the beat
const rateOptions = {
- hitTimestamp: get(stats, 'timestamp'),
- earliestHitTimestamp: get(earliestStats, 'timestamp'),
+ hitTimestamp: stats.timestamp,
+ earliestHitTimestamp: earliestStats.timestamp,
timeWindowMin: start,
timeWindowMax: end,
};
const { rate: bytesSentRate } = calculateRate({
- latestTotal: get(stats, 'metrics.libbeat.output.write.bytes'),
- earliestTotal: get(earliestStats, 'metrics.libbeat.output.write.bytes'),
+ latestTotal: stats.metrics?.libbeat?.output?.write?.bytes,
+ earliestTotal: earliestStats?.metrics?.libbeat?.output?.write?.bytes,
...rateOptions,
});
const { rate: totalEventsRate } = calculateRate({
- latestTotal: get(stats, 'metrics.libbeat.pipeline.events.total'),
- earliestTotal: get(earliestStats, 'metrics.libbeat.pipeline.events.total'),
+ latestTotal: stats.metrics?.libbeat?.pipeline?.events?.total,
+ earliestTotal: earliestStats.metrics?.libbeat?.pipeline?.events?.total,
...rateOptions,
});
- const errorsWrittenLatest = get(stats, 'metrics.libbeat.output.write.errors');
- const errorsWrittenEarliest = get(earliestStats, 'metrics.libbeat.output.write.errors');
- const errorsReadLatest = get(stats, 'metrics.libbeat.output.read.errors');
- const errorsReadEarliest = get(earliestStats, 'metrics.libbeat.output.read.errors');
+ const errorsWrittenLatest = stats.metrics?.libbeat?.output?.write?.errors ?? 0;
+ const errorsWrittenEarliest = earliestStats.metrics?.libbeat?.output?.write?.errors ?? 0;
+ const errorsReadLatest = stats.metrics?.libbeat?.output?.read?.errors ?? 0;
+ const errorsReadEarliest = earliestStats.metrics?.libbeat?.output?.read?.errors ?? 0;
const errors = getDiffCalculation(
errorsWrittenLatest + errorsReadLatest,
errorsWrittenEarliest + errorsReadEarliest
);
accum.beats.push({
- uuid: get(stats, 'beat.uuid'),
- name: get(stats, 'beat.name'),
- type: upperFirst(get(stats, 'beat.type')),
- output: upperFirst(get(stats, 'metrics.libbeat.output.type')),
+ uuid: stats.beat?.uuid,
+ name: stats.beat?.name,
+ type: upperFirst(stats.beat?.type),
+ output: upperFirst(stats.metrics?.libbeat?.output?.type),
total_events_rate: totalEventsRate,
bytes_sent_rate: bytesSentRate,
errors,
- memory: get(stats, 'metrics.beat.memstats.memory_alloc'),
- version: get(stats, 'beat.version'),
- time_of_last_event: get(hit, '_source.timestamp'),
+ memory: stats.metrics?.beat?.memstats?.memory_alloc,
+ version: stats.beat?.version,
+ time_of_last_event: hit._source.timestamp,
});
return accum;
@@ -75,7 +86,7 @@ export function handleResponse(response, start, end) {
return beats;
}
-export async function getApms(req, apmIndexPattern, clusterUuid) {
+export async function getApms(req: LegacyRequest, apmIndexPattern: string, clusterUuid: string) {
checkParam(apmIndexPattern, 'apmIndexPattern in getBeats');
const config = req.server.config();
diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.js b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts
similarity index 60%
rename from x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.js
rename to x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts
index 5d6c38e19bef2a..57325673a131ae 100644
--- a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.js
+++ b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts
@@ -4,52 +4,62 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { upperFirst, get } from 'lodash';
+import { upperFirst } from 'lodash';
+import { LegacyRequest, ElasticsearchResponse } from '../../types';
+// @ts-ignore
import { checkParam } from '../error_missing_required';
+// @ts-ignore
import { createBeatsQuery } from './create_beats_query.js';
+// @ts-ignore
import { getDiffCalculation } from './_beats_stats';
-export function handleResponse(response, beatUuid) {
- const firstStats = get(
- response,
- 'hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats'
- );
- const stats = get(response, 'hits.hits[0]._source.beats_stats');
+export function handleResponse(response: ElasticsearchResponse, beatUuid: string) {
+ if (!response.hits || response.hits.hits.length === 0) {
+ return {};
+ }
- const eventsTotalFirst = get(firstStats, 'metrics.libbeat.pipeline.events.total', null);
- const eventsEmittedFirst = get(firstStats, 'metrics.libbeat.pipeline.events.published', null);
- const eventsDroppedFirst = get(firstStats, 'metrics.libbeat.pipeline.events.dropped', null);
- const bytesWrittenFirst = get(firstStats, 'metrics.libbeat.output.write.bytes', null);
+ const firstStats = response.hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats;
+ const stats = response.hits.hits[0]._source.beats_stats;
- const eventsTotalLast = get(stats, 'metrics.libbeat.pipeline.events.total', null);
- const eventsEmittedLast = get(stats, 'metrics.libbeat.pipeline.events.published', null);
- const eventsDroppedLast = get(stats, 'metrics.libbeat.pipeline.events.dropped', null);
- const bytesWrittenLast = get(stats, 'metrics.libbeat.output.write.bytes', null);
- const handlesHardLimit = get(stats, 'metrics.beat.handles.limit.hard', null);
- const handlesSoftLimit = get(stats, 'metrics.beat.handles.limit.soft', null);
+ const eventsTotalFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.total ?? null;
+ const eventsEmittedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.published ?? null;
+ const eventsDroppedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.dropped ?? null;
+ const bytesWrittenFirst = firstStats?.metrics?.libbeat?.output?.write?.bytes ?? null;
+
+ const eventsTotalLast = stats?.metrics?.libbeat?.pipeline?.events?.total ?? null;
+ const eventsEmittedLast = stats?.metrics?.libbeat?.pipeline?.events?.published ?? null;
+ const eventsDroppedLast = stats?.metrics?.libbeat?.pipeline?.events?.dropped ?? null;
+ const bytesWrittenLast = stats?.metrics?.libbeat?.output?.write?.bytes ?? null;
+ const handlesHardLimit = stats?.metrics?.beat?.handles?.limit?.hard ?? null;
+ const handlesSoftLimit = stats?.metrics?.beat?.handles?.limit?.soft ?? null;
return {
uuid: beatUuid,
- transportAddress: get(stats, 'beat.host', null),
- version: get(stats, 'beat.version', null),
- name: get(stats, 'beat.name', null),
- type: upperFirst(get(stats, 'beat.type')) || null,
- output: upperFirst(get(stats, 'metrics.libbeat.output.type')) || null,
- configReloads: get(stats, 'metrics.libbeat.config.reloads', null),
- uptime: get(stats, 'metrics.beat.info.uptime.ms', null),
- eventsTotal: getDiffCalculation(eventsTotalLast, eventsTotalFirst),
- eventsEmitted: getDiffCalculation(eventsEmittedLast, eventsEmittedFirst),
- eventsDropped: getDiffCalculation(eventsDroppedLast, eventsDroppedFirst),
- bytesWritten: getDiffCalculation(bytesWrittenLast, bytesWrittenFirst),
+ transportAddress: stats?.beat?.host ?? null,
+ version: stats?.beat?.version ?? null,
+ name: stats?.beat?.name ?? null,
+ type: upperFirst(stats?.beat?.type) ?? null,
+ output: upperFirst(stats?.metrics?.libbeat?.output?.type) ?? null,
+ configReloads: stats?.metrics?.libbeat?.config?.reloads ?? null,
+ uptime: stats?.metrics?.beat?.info?.uptime?.ms ?? null,
+ eventsTotal: getDiffCalculation(eventsTotalLast, eventsTotalFirst) ?? null,
+ eventsEmitted: getDiffCalculation(eventsEmittedLast, eventsEmittedFirst) ?? null,
+ eventsDropped: getDiffCalculation(eventsDroppedLast, eventsDroppedFirst) ?? null,
+ bytesWritten: getDiffCalculation(bytesWrittenLast, bytesWrittenFirst) ?? null,
handlesHardLimit,
handlesSoftLimit,
};
}
export async function getBeatSummary(
- req,
- beatsIndexPattern,
- { clusterUuid, beatUuid, start, end }
+ req: LegacyRequest,
+ beatsIndexPattern: string,
+ {
+ clusterUuid,
+ beatUuid,
+ start,
+ end,
+ }: { clusterUuid: string; beatUuid: string; start: number; end: number }
) {
checkParam(beatsIndexPattern, 'beatsIndexPattern in beats/getBeatSummary');
diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts
index a5d70511057976..73eea99467c592 100644
--- a/x-pack/plugins/monitoring/server/types.ts
+++ b/x-pack/plugins/monitoring/server/types.ts
@@ -78,7 +78,9 @@ export interface IBulkUploader {
export interface LegacyRequest {
logger: Logger;
getLogger: (...scopes: string[]) => Logger;
- payload: unknown;
+ payload: {
+ [key: string]: any;
+ };
getKibanaStatsCollector: () => any;
getUiSettingsService: () => any;
getActionTypeRegistry: () => any;
@@ -107,3 +109,80 @@ export interface LegacyRequest {
};
};
}
+
+export interface ElasticsearchResponse {
+ hits?: {
+ hits: ElasticsearchResponseHit[];
+ total: {
+ value: number;
+ };
+ };
+}
+
+export interface ElasticsearchResponseHit {
+ _source: ElasticsearchSource;
+ inner_hits: {
+ [field: string]: {
+ hits: {
+ hits: ElasticsearchResponseHit[];
+ total: {
+ value: number;
+ };
+ };
+ };
+ };
+}
+
+export interface ElasticsearchSource {
+ timestamp: string;
+ beats_stats?: {
+ timestamp?: string;
+ beat?: {
+ uuid?: string;
+ name?: string;
+ type?: string;
+ version?: string;
+ host?: string;
+ };
+ metrics?: {
+ beat?: {
+ memstats?: {
+ memory_alloc?: number;
+ };
+ info?: {
+ uptime?: {
+ ms?: number;
+ };
+ };
+ handles?: {
+ limit?: {
+ hard?: number;
+ soft?: number;
+ };
+ };
+ };
+ libbeat?: {
+ config?: {
+ reloads?: number;
+ };
+ output?: {
+ type?: string;
+ write?: {
+ bytes?: number;
+ errors?: number;
+ };
+ read?: {
+ errors?: number;
+ };
+ };
+ pipeline?: {
+ events?: {
+ total?: number;
+ published?: number;
+ dropped?: number;
+ };
+ };
+ };
+ };
+ };
+}
diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts
index 5a1cdfe867590c..27ea14e3e8ffe3 100644
--- a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts
+++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts
@@ -158,7 +158,7 @@ export class HeadlessChromiumDriver {
): Promise> {
const { timeout } = opts;
logger.debug(`waitForSelector ${selector}`);
- const resp = await this.page.waitFor(selector, { timeout }); // override default 30000ms
+ const resp = await this.page.waitForSelector(selector, { timeout }); // override default 30000ms
logger.debug(`waitForSelector ${selector} resolved`);
return resp;
}
diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts
index efef323612322c..4b42e2cc594256 100644
--- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts
+++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts
@@ -9,13 +9,7 @@ import del from 'del';
import fs from 'fs';
import os from 'os';
import path from 'path';
-import {
- Browser,
- ConsoleMessage,
- LaunchOptions,
- Page,
- Request as PuppeteerRequest,
-} from 'puppeteer';
+import puppeteer from 'puppeteer';
import * as Rx from 'rxjs';
import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber';
import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators';
@@ -26,7 +20,6 @@ import { CaptureConfig } from '../../../../server/types';
import { LevelLogger } from '../../../lib';
import { safeChildProcess } from '../../safe_child_process';
import { HeadlessChromiumDriver } from '../driver';
-import { puppeteerLaunch } from '../puppeteer';
import { args } from './args';
type BrowserConfig = CaptureConfig['browser']['chromium'];
@@ -73,10 +66,10 @@ export class HeadlessChromiumDriverFactory {
const chromiumArgs = this.getChromiumArgs(viewport);
- let browser: Browser;
- let page: Page;
+ let browser: puppeteer.Browser;
+ let page: puppeteer.Page;
try {
- browser = await puppeteerLaunch({
+ browser = await puppeteer.launch({
pipe: !this.browserConfig.inspect,
userDataDir: this.userDataDir,
executablePath: this.binaryPath,
@@ -85,7 +78,7 @@ export class HeadlessChromiumDriverFactory {
env: {
TZ: browserTimezone,
},
- } as LaunchOptions);
+ } as puppeteer.LaunchOptions);
page = await browser.newPage();
@@ -160,8 +153,8 @@ export class HeadlessChromiumDriverFactory {
});
}
- getBrowserLogger(page: Page, logger: LevelLogger): Rx.Observable {
- const consoleMessages$ = Rx.fromEvent(page, 'console').pipe(
+ getBrowserLogger(page: puppeteer.Page, logger: LevelLogger): Rx.Observable {
+ const consoleMessages$ = Rx.fromEvent(page, 'console').pipe(
map((line) => {
if (line.type() === 'error') {
logger.error(line.text(), ['headless-browser-console']);
@@ -171,7 +164,7 @@ export class HeadlessChromiumDriverFactory {
})
);
- const pageRequestFailed$ = Rx.fromEvent(page, 'requestfailed').pipe(
+ const pageRequestFailed$ = Rx.fromEvent(page, 'requestfailed').pipe(
map((req) => {
const failure = req.failure && req.failure();
if (failure) {
@@ -185,7 +178,7 @@ export class HeadlessChromiumDriverFactory {
return Rx.merge(consoleMessages$, pageRequestFailed$);
}
- getProcessLogger(browser: Browser, logger: LevelLogger): Rx.Observable {
+ getProcessLogger(browser: puppeteer.Browser, logger: LevelLogger): Rx.Observable {
const childProcess = browser.process();
// NOTE: The browser driver can not observe stdout and stderr of the child process
// Puppeteer doesn't give a handle to the original ChildProcess object
@@ -201,7 +194,7 @@ export class HeadlessChromiumDriverFactory {
return processClose$; // ideally, this would also merge with observers for stdout and stderr
}
- getPageExit(browser: Browser, page: Page) {
+ getPageExit(browser: puppeteer.Browser, page: puppeteer.Page) {
const pageError$ = Rx.fromEvent(page, 'error').pipe(
mergeMap((err) => {
return Rx.throwError(
diff --git a/x-pack/plugins/reporting/server/browsers/chromium/paths.ts b/x-pack/plugins/reporting/server/browsers/chromium/paths.ts
index c22db895b451e0..61a268460bd1ba 100644
--- a/x-pack/plugins/reporting/server/browsers/chromium/paths.ts
+++ b/x-pack/plugins/reporting/server/browsers/chromium/paths.ts
@@ -13,34 +13,34 @@ export const paths = {
{
platforms: ['darwin', 'freebsd', 'openbsd'],
architecture: 'x64',
- archiveFilename: 'chromium-312d84c-darwin.zip',
- archiveChecksum: '020303e829745fd332ae9b39442ce570',
- binaryChecksum: '5cdec11d45a0eddf782bed9b9f10319f',
- binaryRelativePath: 'headless_shell-darwin/headless_shell',
+ archiveFilename: 'chromium-ef768c9-darwin_x64.zip',
+ archiveChecksum: 'd87287f6b2159cff7c64babac873cc73',
+ binaryChecksum: '8d777b3380a654e2730fc36afbfb11e1',
+ binaryRelativePath: 'headless_shell-darwin_x64/headless_shell',
},
{
platforms: ['linux'],
architecture: 'x64',
- archiveFilename: 'chromium-312d84c-linux.zip',
- archiveChecksum: '15ba9166a42f93ee92e42217b737018d',
- binaryChecksum: 'c7fe36ed3e86a6dd23323be0a4e8c0fd',
- binaryRelativePath: 'headless_shell-linux/headless_shell',
+ archiveFilename: 'chromium-ef768c9-linux_x64.zip',
+ archiveChecksum: '85575e8fd56849f4de5e3584e05712c0',
+ binaryChecksum: '38c4d849c17683def1283d7e5aa56fe9',
+ binaryRelativePath: 'headless_shell-linux_x64/headless_shell',
},
{
platforms: ['linux'],
architecture: 'arm64',
- archiveFilename: 'chromium-312d84c-linux_arm64.zip',
- archiveChecksum: 'aa4d5b99dd2c1bd8e614e67f63a48652',
- binaryChecksum: '7fdccff319396f0aee7f269dd85fe6fc',
+ archiveFilename: 'chromium-ef768c9-linux_arm64.zip',
+ archiveChecksum: '20b09b70476bea76a276c583bf72eac7',
+ binaryChecksum: 'dcfd277800c1a5c7d566c445cbdc225c',
binaryRelativePath: 'headless_shell-linux_arm64/headless_shell',
},
{
platforms: ['win32'],
architecture: 'x64',
- archiveFilename: 'chromium-312d84c-windows.zip',
- archiveChecksum: '3e36adfb755dacacc226ed5fd6b43105',
- binaryChecksum: '9913e431fbfc7dfcd958db74ace4d58b',
- binaryRelativePath: 'headless_shell-windows\\headless_shell.exe',
+ archiveFilename: 'chromium-ef768c9-windows_x64.zip',
+ archiveChecksum: '33301c749b5305b65311742578c52f15',
+ binaryChecksum: '9f28dd56c7a304a22bf66f0097fa4de9',
+ binaryRelativePath: 'headless_shell-windows_x64\\headless_shell.exe',
},
],
};
diff --git a/x-pack/plugins/reporting/server/browsers/chromium/puppeteer.ts b/x-pack/plugins/reporting/server/browsers/chromium/puppeteer.ts
deleted file mode 100644
index caa25aab06287e..00000000000000
--- a/x-pack/plugins/reporting/server/browsers/chromium/puppeteer.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import puppeteer from 'puppeteer';
-// @ts-ignore lacking typedefs which this module fixes
-import puppeteerCore from 'puppeteer-core';
-
-export const puppeteerLaunch: (
- opts?: puppeteer.LaunchOptions
-) => Promise = puppeteerCore.launch.bind(puppeteerCore);
diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts
index 798f926cd0a312..c945801dd49c24 100644
--- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts
+++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts
@@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-jest.mock('../../browsers/chromium/puppeteer', () => ({
- puppeteerLaunch: () => ({
+jest.mock('puppeteer', () => ({
+ launch: () => ({
// Fixme needs event emitters
newPage: () => ({
setDefaultTimeout: jest.fn(),
diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts
index 6aba78c9360710..2e003b1d55eac5 100644
--- a/x-pack/plugins/security/server/audit/audit_events.ts
+++ b/x-pack/plugins/security/server/audit/audit_events.ts
@@ -45,7 +45,7 @@ export interface AuditEvent {
*/
saved_object?: {
type: string;
- id?: string;
+ id: string;
};
/**
* Any additional event specific fields.
@@ -178,7 +178,9 @@ export enum SavedObjectAction {
REMOVE_REFERENCES = 'saved_object_remove_references',
}
-const eventVerbs = {
+type VerbsTuple = [string, string, string];
+
+const eventVerbs: Record = {
saved_object_create: ['create', 'creating', 'created'],
saved_object_get: ['access', 'accessing', 'accessed'],
saved_object_update: ['update', 'updating', 'updated'],
@@ -193,7 +195,7 @@ const eventVerbs = {
],
};
-const eventTypes = {
+const eventTypes: Record = {
saved_object_create: EventType.CREATION,
saved_object_get: EventType.ACCESS,
saved_object_update: EventType.CHANGE,
@@ -204,10 +206,10 @@ const eventTypes = {
saved_object_remove_references: EventType.CHANGE,
};
-export interface SavedObjectParams {
+export interface SavedObjectEventParams {
action: SavedObjectAction;
outcome?: EventOutcome;
- savedObject?: Required['kibana']>['saved_object'];
+ savedObject?: NonNullable['saved_object'];
addToSpaces?: readonly string[];
deleteFromSpaces?: readonly string[];
error?: Error;
@@ -220,12 +222,12 @@ export function savedObjectEvent({
deleteFromSpaces,
outcome,
error,
-}: SavedObjectParams): AuditEvent | undefined {
+}: SavedObjectEventParams): AuditEvent | undefined {
const doc = savedObject ? `${savedObject.type} [id=${savedObject.id}]` : 'saved objects';
const [present, progressive, past] = eventVerbs[action];
const message = error
? `Failed attempt to ${present} ${doc}`
- : outcome === 'unknown'
+ : outcome === EventOutcome.UNKNOWN
? `User is ${progressive} ${doc}`
: `User has ${past} ${doc}`;
const type = eventTypes[action];
diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts
index 04db65f88cda0a..d99fbc702a0782 100644
--- a/x-pack/plugins/security/server/index.ts
+++ b/x-pack/plugins/security/server/index.ts
@@ -27,7 +27,14 @@ export {
SAMLLogin,
OIDCLogin,
} from './authentication';
-export { LegacyAuditLogger } from './audit';
+export {
+ LegacyAuditLogger,
+ AuditLogger,
+ AuditEvent,
+ EventCategory,
+ EventType,
+ EventOutcome,
+} from './audit';
export { SecurityPluginSetup };
export { AuthenticatedUser } from '../common/model';
diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts
index c6f4ca6dd8afe6..15ca8bac89bd61 100644
--- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts
+++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts
@@ -12,6 +12,18 @@ import { SavedObjectsClientContract } from 'kibana/server';
import { SavedObjectActions } from '../authorization/actions/saved_object';
import { AuditEvent, EventOutcome } from '../audit';
+jest.mock('../../../../../src/core/server/saved_objects/service/lib/utils', () => {
+ const { SavedObjectsUtils } = jest.requireActual(
+ '../../../../../src/core/server/saved_objects/service/lib/utils'
+ );
+ return {
+ SavedObjectsUtils: {
+ createEmptyFindResponse: SavedObjectsUtils.createEmptyFindResponse,
+ generateId: () => 'mock-saved-object-id',
+ },
+ };
+});
+
let clientOpts: ReturnType;
let client: SecureSavedObjectsClientWrapper;
const USERNAME = Symbol();
@@ -551,7 +563,7 @@ describe('#bulkGet', () => {
});
test(`adds audit event when successful`, async () => {
- const apiCallReturnValue = { saved_objects: [], foo: 'bar' };
+ const apiCallReturnValue = { saved_objects: [obj1, obj2], foo: 'bar' };
clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any);
const objects = [obj1, obj2];
const options = { namespace };
@@ -686,7 +698,7 @@ describe('#create', () => {
});
test(`throws decorated ForbiddenError when unauthorized`, async () => {
- const options = { namespace };
+ const options = { id: 'mock-saved-object-id', namespace };
await expectForbiddenError(client.create, { type, attributes, options });
});
@@ -694,8 +706,12 @@ describe('#create', () => {
const apiCallReturnValue = Symbol();
clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any);
- const options = { namespace };
- const result = await expectSuccess(client.create, { type, attributes, options });
+ const options = { id: 'mock-saved-object-id', namespace };
+ const result = await expectSuccess(client.create, {
+ type,
+ attributes,
+ options,
+ });
expect(result).toBe(apiCallReturnValue);
});
@@ -721,17 +737,17 @@ describe('#create', () => {
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = Symbol();
clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any);
- const options = { namespace };
+ const options = { id: 'mock-saved-object-id', namespace };
await expectSuccess(client.create, { type, attributes, options });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
- expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type });
+ expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type, id: expect.any(String) });
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.create(type, attributes, { namespace })).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
- expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type });
+ expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type, id: expect.any(String) });
});
});
diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
index e6e34de4ac9abf..765274a839efab 100644
--- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
+++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
@@ -96,15 +96,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
attributes: T = {} as T,
options: SavedObjectsCreateOptions = {}
) {
- const namespaces = [options.namespace, ...(options.initialNamespaces || [])];
+ const optionsWithId = { ...options, id: options.id ?? SavedObjectsUtils.generateId() };
+ const namespaces = [optionsWithId.namespace, ...(optionsWithId.initialNamespaces || [])];
try {
- const args = { type, attributes, options };
+ const args = { type, attributes, options: optionsWithId };
await this.ensureAuthorized(type, 'create', namespaces, { args });
} catch (error) {
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.CREATE,
- savedObject: { type, id: options.id },
+ savedObject: { type, id: optionsWithId.id },
error,
})
);
@@ -114,11 +115,11 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
savedObjectEvent({
action: SavedObjectAction.CREATE,
outcome: EventOutcome.UNKNOWN,
- savedObject: { type, id: options.id },
+ savedObject: { type, id: optionsWithId.id },
})
);
- const savedObject = await this.baseClient.create(type, attributes, options);
+ const savedObject = await this.baseClient.create(type, attributes, optionsWithId);
return await this.redactSavedObjectNamespaces(savedObject, namespaces);
}
@@ -141,17 +142,26 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
objects: Array>,
options: SavedObjectsBaseOptions = {}
) {
- const namespaces = objects.reduce(
+ const objectsWithId = objects.map((obj) => ({
+ ...obj,
+ id: obj.id ?? SavedObjectsUtils.generateId(),
+ }));
+ const namespaces = objectsWithId.reduce(
(acc, { initialNamespaces = [] }) => acc.concat(initialNamespaces),
[options.namespace]
);
try {
- const args = { objects, options };
- await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_create', namespaces, {
- args,
- });
+ const args = { objects: objectsWithId, options };
+ await this.ensureAuthorized(
+ this.getUniqueObjectTypes(objectsWithId),
+ 'bulk_create',
+ namespaces,
+ {
+ args,
+ }
+ );
} catch (error) {
- objects.forEach(({ type, id }) =>
+ objectsWithId.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.CREATE,
@@ -162,7 +172,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
);
throw error;
}
- objects.forEach(({ type, id }) =>
+ objectsWithId.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.CREATE,
@@ -172,7 +182,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
)
);
- const response = await this.baseClient.bulkCreate(objects, options);
+ const response = await this.baseClient.bulkCreate(objectsWithId, options);
return await this.redactSavedObjectsNamespaces(response, namespaces);
}
@@ -284,14 +294,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
const response = await this.baseClient.bulkGet(objects, options);
- objects.forEach(({ type, id }) =>
- this.auditLogger.log(
- savedObjectEvent({
- action: SavedObjectAction.GET,
- savedObject: { type, id },
- })
- )
- );
+ response.saved_objects.forEach(({ error, type, id }) => {
+ if (!error) {
+ this.auditLogger.log(
+ savedObjectEvent({
+ action: SavedObjectAction.GET,
+ savedObject: { type, id },
+ })
+ );
+ }
+ });
return await this.redactSavedObjectsNamespaces(response, [options.namespace]);
}
diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts
index c47ec703418453..cc7e8df757c1d2 100644
--- a/x-pack/plugins/security_solution/common/constants.ts
+++ b/x-pack/plugins/security_solution/common/constants.ts
@@ -194,5 +194,3 @@ export const showAllOthersBucket: string[] = [
'destination.ip',
'user.name',
];
-
-export const ENABLE_NEW_TIMELINE = false;
diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts
index b516f7c57a96dd..1b70a13935b7d4 100644
--- a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts
@@ -12,6 +12,7 @@ describe('7.11.0 Endpoint Package Policy migration', () => {
const migration = migratePackagePolicyToV7110;
it('adds malware notification checkbox and optional message and adds AV registration config', () => {
const doc: SavedObjectUnsanitizedDoc = {
+ id: 'mock-saved-object-id',
attributes: {
name: 'Some Policy Name',
package: {
@@ -100,11 +101,13 @@ describe('7.11.0 Endpoint Package Policy migration', () => {
],
},
type: ' nested',
+ id: 'mock-saved-object-id',
});
});
it('does not modify non-endpoint package policies', () => {
const doc: SavedObjectUnsanitizedDoc = {
+ id: 'mock-saved-object-id',
attributes: {
name: 'Some Policy Name',
package: {
@@ -164,6 +167,7 @@ describe('7.11.0 Endpoint Package Policy migration', () => {
],
},
type: ' nested',
+ id: 'mock-saved-object-id',
});
});
});
diff --git a/x-pack/plugins/security_solution/cypress/cypress.json b/x-pack/plugins/security_solution/cypress/cypress.json
index 364db54b4b5d9b..d934afec127c20 100644
--- a/x-pack/plugins/security_solution/cypress/cypress.json
+++ b/x-pack/plugins/security_solution/cypress/cypress.json
@@ -8,5 +8,7 @@
"screenshotsFolder": "../../../target/kibana-security-solution/cypress/screenshots",
"trashAssetsBeforeRuns": false,
"video": false,
- "videosFolder": "../../../target/kibana-security-solution/cypress/videos"
+ "videosFolder": "../../../target/kibana-security-solution/cypress/videos",
+ "viewportHeight": 900,
+ "viewportWidth": 1440
}
diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts
index 31d8e4666d91de..1cece57c2fea58 100644
--- a/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts
@@ -35,7 +35,7 @@ describe('Alerts timeline', () => {
.invoke('text')
.then((eventId) => {
investigateFirstAlertInTimeline();
- cy.get(PROVIDER_BADGE).should('have.text', `_id: "${eventId}"`);
+ cy.get(PROVIDER_BADGE).filter(':visible').should('have.text', `_id: "${eventId}"`);
});
});
});
diff --git a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts
index b32402851ac7c6..6716186cddd451 100644
--- a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts
@@ -8,10 +8,10 @@ import { case1 } from '../objects/case';
import {
ALL_CASES_CLOSE_ACTION,
- ALL_CASES_CLOSED_CASES_COUNT,
ALL_CASES_CLOSED_CASES_STATS,
ALL_CASES_COMMENTS_COUNT,
ALL_CASES_DELETE_ACTION,
+ ALL_CASES_IN_PROGRESS_CASES_STATS,
ALL_CASES_NAME,
ALL_CASES_OPEN_CASES_COUNT,
ALL_CASES_OPEN_CASES_STATS,
@@ -70,8 +70,8 @@ describe('Cases', () => {
cy.get(ALL_CASES_PAGE_TITLE).should('have.text', 'Cases');
cy.get(ALL_CASES_OPEN_CASES_STATS).should('have.text', 'Open cases1');
cy.get(ALL_CASES_CLOSED_CASES_STATS).should('have.text', 'Closed cases0');
- cy.get(ALL_CASES_OPEN_CASES_COUNT).should('have.text', 'Open cases (1)');
- cy.get(ALL_CASES_CLOSED_CASES_COUNT).should('have.text', 'Closed cases (0)');
+ cy.get(ALL_CASES_IN_PROGRESS_CASES_STATS).should('have.text', 'In progress cases0');
+ cy.get(ALL_CASES_OPEN_CASES_COUNT).should('have.text', 'Open (1)');
cy.get(ALL_CASES_REPORTERS_COUNT).should('have.text', 'Reporter1');
cy.get(ALL_CASES_TAGS_COUNT).should('have.text', 'Tags2');
cy.get(ALL_CASES_NAME).should('have.text', case1.name);
@@ -89,7 +89,7 @@ describe('Cases', () => {
const expectedTags = case1.tags.join('');
cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', case1.name);
- cy.get(CASE_DETAILS_STATUS).should('have.text', 'open');
+ cy.get(CASE_DETAILS_STATUS).should('have.text', 'Open');
cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', case1.reporter);
cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description');
cy.get(CASE_DETAILS_DESCRIPTION).should(
@@ -103,8 +103,8 @@ describe('Cases', () => {
openCaseTimeline();
- cy.get(TIMELINE_TITLE).should('have.attr', 'value', case1.timeline.title);
- cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', case1.timeline.description);
+ cy.get(TIMELINE_TITLE).contains(case1.timeline.title);
+ cy.get(TIMELINE_DESCRIPTION).contains(case1.timeline.description);
cy.get(TIMELINE_QUERY).invoke('text').should('eq', case1.timeline.query);
});
});
diff --git a/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts b/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts
index c19e51c3ada408..b84b668a28502a 100644
--- a/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts
@@ -13,11 +13,7 @@ import {
import { closesModal, openStatsAndTables } from '../tasks/inspect';
import { loginAndWaitForPage } from '../tasks/login';
import { openTimelineUsingToggle } from '../tasks/security_main';
-import {
- executeTimelineKQL,
- openTimelineInspectButton,
- openTimelineSettings,
-} from '../tasks/timeline';
+import { executeTimelineKQL, openTimelineInspectButton } from '../tasks/timeline';
import { HOSTS_URL, NETWORK_URL } from '../urls/navigation';
@@ -60,7 +56,6 @@ describe('Inspect', () => {
loginAndWaitForPage(HOSTS_URL);
openTimelineUsingToggle();
executeTimelineKQL(hostExistsQuery);
- openTimelineSettings();
openTimelineInspectButton();
cy.get(INSPECT_MODAL).should('be.visible');
});
diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts
index f62db083172a40..8bfb6eba3e1fd6 100644
--- a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts
@@ -24,23 +24,20 @@ import { createNewTimeline } from '../tasks/timeline';
import { HOSTS_URL } from '../urls/navigation';
-describe('timeline data providers', () => {
+// FLAKY: https://github.com/elastic/kibana/issues/62060
+describe.skip('timeline data providers', () => {
before(() => {
loginAndWaitForPage(HOSTS_URL);
waitForAllHostsToBeLoaded();
});
- beforeEach(() => {
- openTimelineUsingToggle();
- });
-
afterEach(() => {
createNewTimeline();
});
it('renders the data provider of a host dragged from the All Hosts widget on the hosts page', () => {
dragAndDropFirstHostToTimeline();
-
+ openTimelineUsingToggle();
cy.get(TIMELINE_DROPPED_DATA_PROVIDERS)
.first()
.invoke('text')
@@ -57,26 +54,28 @@ describe('timeline data providers', () => {
it('sets the background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the data providers', () => {
dragFirstHostToTimeline();
- cy.get(TIMELINE_DATA_PROVIDERS).should(
- 'have.css',
- 'background',
- 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box'
- );
+ cy.get(TIMELINE_DATA_PROVIDERS)
+ .filter(':visible')
+ .should(
+ 'have.css',
+ 'background',
+ 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box'
+ );
});
it('sets the background to euiColorSuccess with a 20% alpha channel and renders the dashed border color as euiColorSuccess when the user starts dragging a host AND is hovering over the data providers', () => {
dragFirstHostToEmptyTimelineDataProviders();
- cy.get(TIMELINE_DATA_PROVIDERS_EMPTY).should(
- 'have.css',
- 'background',
- 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box'
- );
+ cy.get(TIMELINE_DATA_PROVIDERS_EMPTY)
+ .filter(':visible')
+ .should(
+ 'have.css',
+ 'background',
+ 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box'
+ );
- cy.get(TIMELINE_DATA_PROVIDERS).should(
- 'have.css',
- 'border',
- '3.1875px dashed rgb(1, 125, 115)'
- );
+ cy.get(TIMELINE_DATA_PROVIDERS)
+ .filter(':visible')
+ .should('have.css', 'border', '3.1875px dashed rgb(1, 125, 115)');
});
});
diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts
index 9b3434b5521d4c..33e8cc40b12398 100644
--- a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts
@@ -4,12 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { TIMELINE_FLYOUT_HEADER, TIMELINE_NOT_READY_TO_DROP_BUTTON } from '../screens/timeline';
+import { TIMELINE_FLYOUT_HEADER, TIMELINE_DATA_PROVIDERS } from '../screens/timeline';
import { dragFirstHostToTimeline, waitForAllHostsToBeLoaded } from '../tasks/hosts/all_hosts';
import { loginAndWaitForPage } from '../tasks/login';
-import { openTimelineUsingToggle, openTimelineIfClosed } from '../tasks/security_main';
-import { createNewTimeline } from '../tasks/timeline';
+import { openTimelineUsingToggle, closeTimelineUsingToggle } from '../tasks/security_main';
import { HOSTS_URL } from '../urls/navigation';
@@ -19,23 +18,21 @@ describe('timeline flyout button', () => {
waitForAllHostsToBeLoaded();
});
- afterEach(() => {
- openTimelineIfClosed();
- createNewTimeline();
- });
-
it('toggles open the timeline', () => {
openTimelineUsingToggle();
cy.get(TIMELINE_FLYOUT_HEADER).should('have.css', 'visibility', 'visible');
+ closeTimelineUsingToggle();
});
- it('sets the flyout button background to euiColorSuccess with a 20% alpha channel when the user starts dragging a host, but is not hovering over the flyout button', () => {
+ it('sets the data providers background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the data providers area', () => {
dragFirstHostToTimeline();
- cy.get(TIMELINE_NOT_READY_TO_DROP_BUTTON).should(
- 'have.css',
- 'background',
- 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box'
- );
+ cy.get(TIMELINE_DATA_PROVIDERS)
+ .filter(':visible')
+ .should(
+ 'have.css',
+ 'background',
+ 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box'
+ );
});
});
diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts
index 8dcb5e144c24ff..bf8a01f6cf072d 100644
--- a/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts
@@ -10,7 +10,6 @@ import { loginAndWaitForPageWithoutDateRange } from '../tasks/login';
import { timeline as timelineTemplate } from '../objects/timeline';
import { TIMELINE_TEMPLATES_URL } from '../urls/navigation';
-import { openTimelineUsingToggle } from '../tasks/security_main';
import { addNameToTimeline, closeTimeline, createNewTimelineTemplate } from '../tasks/timeline';
describe('Export timelines', () => {
@@ -23,7 +22,6 @@ describe('Export timelines', () => {
it('Exports a custom timeline template', async () => {
loginAndWaitForPageWithoutDateRange(TIMELINE_TEMPLATES_URL);
- openTimelineUsingToggle();
createNewTimelineTemplate();
addNameToTimeline(timelineTemplate.title);
closeTimeline();
diff --git a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts
index 906fba28a7721c..3a941209de7366 100644
--- a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts
@@ -228,6 +228,7 @@ describe('url state', () => {
cy.server();
cy.route('PATCH', '**/api/timeline').as('timeline');
+ waitForTimelineChanges();
addNameToTimeline(timeline.title);
waitForTimelineChanges();
@@ -242,7 +243,7 @@ describe('url state', () => {
cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).should('not.have.text', 'Updating');
cy.get(TIMELINE).should('be.visible');
cy.get(TIMELINE_TITLE).should('be.visible');
- cy.get(TIMELINE_TITLE).should('have.attr', 'value', timeline.title);
+ cy.get(TIMELINE_TITLE).should('have.text', timeline.title);
});
});
});
diff --git a/x-pack/plugins/security_solution/cypress/screens/all_cases.ts b/x-pack/plugins/security_solution/cypress/screens/all_cases.ts
index dc0e764744f845..1b801f6a454591 100644
--- a/x-pack/plugins/security_solution/cypress/screens/all_cases.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/all_cases.ts
@@ -10,8 +10,6 @@ export const ALL_CASES_CASE = (id: string) => {
export const ALL_CASES_CLOSE_ACTION = '[data-test-subj="action-close"]';
-export const ALL_CASES_CLOSED_CASES_COUNT = '[data-test-subj="closed-case-count"]';
-
export const ALL_CASES_CLOSED_CASES_STATS = '[data-test-subj="closedStatsHeader"]';
export const ALL_CASES_COMMENTS_COUNT = '[data-test-subj="case-table-column-commentCount"]';
@@ -22,9 +20,11 @@ export const ALL_CASES_CREATE_NEW_CASE_TABLE_BTN = '[data-test-subj="cases-table
export const ALL_CASES_DELETE_ACTION = '[data-test-subj="action-delete"]';
+export const ALL_CASES_IN_PROGRESS_CASES_STATS = '[data-test-subj="inProgressStatsHeader"]';
+
export const ALL_CASES_NAME = '[data-test-subj="case-details-link"]';
-export const ALL_CASES_OPEN_CASES_COUNT = '[data-test-subj="open-case-count"]';
+export const ALL_CASES_OPEN_CASES_COUNT = '[data-test-subj="case-status-filter"]';
export const ALL_CASES_OPEN_CASES_STATS = '[data-test-subj="openStatsHeader"]';
diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts
index 02ec74aaed29c3..e9a258c70cb231 100644
--- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts
@@ -14,7 +14,7 @@ export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]';
export const CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN =
'[data-test-subj="push-to-external-service"]';
-export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]';
+export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status-dropdown"]';
export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]';
diff --git a/x-pack/plugins/security_solution/cypress/screens/date_picker.ts b/x-pack/plugins/security_solution/cypress/screens/date_picker.ts
index e49f5afa7bd0c4..967a56fc6f63de 100644
--- a/x-pack/plugins/security_solution/cypress/screens/date_picker.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/date_picker.ts
@@ -10,7 +10,7 @@ export const DATE_PICKER_APPLY_BUTTON =
'[data-test-subj="globalDatePicker"] button[data-test-subj="querySubmitButton"]';
export const DATE_PICKER_APPLY_BUTTON_TIMELINE =
- '[data-test-subj="timeline-properties"] button[data-test-subj="superDatePickerApplyTimeButton"]';
+ '[data-test-subj="timeline-date-picker-container"] button[data-test-subj="superDatePickerApplyTimeButton"]';
export const DATE_PICKER_ABSOLUTE_TAB = '[data-test-subj="superDatePickerAbsoluteTab"]';
@@ -18,10 +18,10 @@ export const DATE_PICKER_END_DATE_POPOVER_BUTTON =
'[data-test-subj="globalDatePicker"] [data-test-subj="superDatePickerendDatePopoverButton"]';
export const DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE =
- '[data-test-subj="timeline-properties"] [data-test-subj="superDatePickerendDatePopoverButton"]';
+ '[data-test-subj="timeline-date-picker-container"] [data-test-subj="superDatePickerendDatePopoverButton"]';
export const DATE_PICKER_START_DATE_POPOVER_BUTTON =
'div[data-test-subj="globalDatePicker"] button[data-test-subj="superDatePickerstartDatePopoverButton"]';
export const DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE =
- '[data-test-subj="timeline-properties"] [data-test-subj="superDatePickerstartDatePopoverButton"]';
+ '[data-test-subj="timeline-date-picker-container"] [data-test-subj="superDatePickerstartDatePopoverButton"]';
diff --git a/x-pack/plugins/security_solution/cypress/screens/security_main.ts b/x-pack/plugins/security_solution/cypress/screens/security_main.ts
index d4eeeb036ee956..c6c1067825f16d 100644
--- a/x-pack/plugins/security_solution/cypress/screens/security_main.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/security_main.ts
@@ -7,3 +7,5 @@
export const MAIN_PAGE = '[data-test-subj="kibanaChrome"]';
export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="flyoutOverlay"]';
+
+export const TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON = `[data-test-subj="flyoutBottomBar"] ${TIMELINE_TOGGLE_BUTTON}`;
diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts
index 98e6502ffe94fe..ea0e132bf07b51 100644
--- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts
@@ -10,7 +10,9 @@ export const ADD_NOTE_BUTTON = '[data-test-subj="add-note"]';
export const ADD_FILTER = '[data-test-subj="timeline"] [data-test-subj="addFilter"]';
-export const ATTACH_TIMELINE_TO_NEW_CASE_ICON = '[data-test-subj="attach-timeline-case"]';
+export const ATTACH_TIMELINE_TO_CASE_BUTTON = '[data-test-subj="attach-timeline-case-button"]';
+
+export const ATTACH_TIMELINE_TO_NEW_CASE_ICON = '[data-test-subj="attach-timeline-new-case"]';
export const ATTACH_TIMELINE_TO_EXISTING_CASE_ICON =
'[data-test-subj="attach-timeline-existing-case"]';
@@ -90,6 +92,8 @@ export const TIMELINE_DATA_PROVIDERS_EMPTY =
export const TIMELINE_DESCRIPTION = '[data-test-subj="timeline-description"]';
+export const TIMELINE_DESCRIPTION_INPUT = '[data-test-subj="timeline-description-input"]';
+
export const TIMELINE_DROPPED_DATA_PROVIDERS = '[data-test-subj="providerContainer"]';
export const TIMELINE_FIELDS_BUTTON =
@@ -108,23 +112,28 @@ export const TIMELINE_FILTER_OPERATOR = '[data-test-subj="filterOperatorList"]';
export const TIMELINE_FILTER_VALUE =
'[data-test-subj="filterParamsComboBox phraseParamsComboxBox"]';
+export const TIMELINE_FLYOUT = '[data-test-subj="eui-flyout"]';
+
export const TIMELINE_FLYOUT_HEADER = '[data-test-subj="eui-flyout-header"]';
export const TIMELINE_FLYOUT_BODY = '[data-test-subj="eui-flyout-body"]';
-export const TIMELINE_INSPECT_BUTTON = '[data-test-subj="inspect-empty-button"]';
-
-export const TIMELINE_NOT_READY_TO_DROP_BUTTON =
- '[data-test-subj="flyout-button-not-ready-to-drop"]';
+export const TIMELINE_INSPECT_BUTTON = `${TIMELINE_FLYOUT} [data-test-subj="inspect-icon-button"]`;
export const TIMELINE_QUERY = '[data-test-subj="timelineQueryInput"]';
-export const TIMELINE_SETTINGS_ICON = '[data-test-subj="settings-gear"]';
+export const TIMELINE_SETTINGS_ICON = '[data-test-subj="settings-plus-in-circle"]';
export const TIMELINE_TITLE = '[data-test-subj="timeline-title"]';
+export const TIMELINE_TITLE_INPUT = '[data-test-subj="timeline-title-input"]';
+
export const TIMESTAMP_HEADER_FIELD = '[data-test-subj="header-text-@timestamp"]';
export const TIMESTAMP_TOGGLE_FIELD = '[data-test-subj="toggle-field-@timestamp"]';
export const TOGGLE_TIMELINE_EXPAND_EVENT = '[data-test-subj="expand-event"]';
+
+export const TIMELINE_EDIT_MODAL_OPEN_BUTTON = '[data-test-subj="save-timeline-button-icon"]';
+
+export const TIMELINE_EDIT_MODAL_SAVE_BUTTON = '[data-test-subj="save-button"]';
diff --git a/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts b/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts
index 27d17f966d8fc8..c52ca0b968c372 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts
@@ -13,7 +13,9 @@ export const dragAndDropFirstHostToTimeline = () => {
cy.get(HOSTS_NAMES_DRAGGABLE)
.first()
.then((firstHost) => drag(firstHost));
- cy.get(TIMELINE_DATA_PROVIDERS).then((dataProvidersDropArea) => drop(dataProvidersDropArea));
+ cy.get(TIMELINE_DATA_PROVIDERS)
+ .filter(':visible')
+ .then((dataProvidersDropArea) => drop(dataProvidersDropArea));
};
export const dragFirstHostToEmptyTimelineDataProviders = () => {
@@ -21,9 +23,9 @@ export const dragFirstHostToEmptyTimelineDataProviders = () => {
.first()
.then((host) => drag(host));
- cy.get(TIMELINE_DATA_PROVIDERS_EMPTY).then((dataProvidersDropArea) =>
- dragWithoutDrop(dataProvidersDropArea)
- );
+ cy.get(TIMELINE_DATA_PROVIDERS_EMPTY)
+ .filter(':visible')
+ .then((dataProvidersDropArea) => dragWithoutDrop(dataProvidersDropArea));
};
export const dragFirstHostToTimeline = () => {
diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts
index 9f385d9ccd2fcd..d927ac5cd9d2b3 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/login.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts
@@ -219,7 +219,6 @@ const loginViaConfig = () => {
*/
export const loginAndWaitForPage = (url: string, role?: RolesType) => {
login(role);
- cy.viewport('macbook-15');
cy.visit(
`${url}?timerange=(global:(linkTo:!(timeline),timerange:(from:1547914976217,fromStr:'2019-01-19T16:22:56.217Z',kind:relative,to:1579537385745,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1547914976217,fromStr:'2019-01-19T16:22:56.217Z',kind:relative,to:1579537385745,toStr:now)))`
);
@@ -228,7 +227,6 @@ export const loginAndWaitForPage = (url: string, role?: RolesType) => {
export const loginAndWaitForPageWithoutDateRange = (url: string, role?: RolesType) => {
login(role);
- cy.viewport('macbook-15');
cy.visit(role ? getUrlWithRoute(role, url) : url);
cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 });
};
@@ -237,7 +235,6 @@ export const loginAndWaitForTimeline = (timelineId: string, role?: RolesType) =>
const route = `/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t)`;
login(role);
- cy.viewport('macbook-15');
cy.visit(role ? getUrlWithRoute(role, route) : route);
cy.get('[data-test-subj="headerGlobalNav"]');
cy.get(TIMELINE_FLYOUT_BODY).should('be.visible');
diff --git a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts
index dd01159e3029fa..eb03c56ef04e88 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts
@@ -4,15 +4,23 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { MAIN_PAGE, TIMELINE_TOGGLE_BUTTON } from '../screens/security_main';
+import {
+ MAIN_PAGE,
+ TIMELINE_TOGGLE_BUTTON,
+ TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON,
+} from '../screens/security_main';
export const openTimelineUsingToggle = () => {
- cy.get(TIMELINE_TOGGLE_BUTTON).click();
+ cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).click();
+};
+
+export const closeTimelineUsingToggle = () => {
+ cy.get(TIMELINE_TOGGLE_BUTTON).filter(':visible').click();
};
export const openTimelineIfClosed = () =>
cy.get(MAIN_PAGE).then(($page) => {
- if ($page.find(TIMELINE_TOGGLE_BUTTON).length === 1) {
+ if ($page.find(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).length === 1) {
openTimelineUsingToggle();
}
});
diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts
index b101793385488b..10a2ff27666c05 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts
@@ -11,6 +11,7 @@ import { ALL_CASES_CREATE_NEW_CASE_TABLE_BTN } from '../screens/all_cases';
import {
ADD_FILTER,
ADD_NOTE_BUTTON,
+ ATTACH_TIMELINE_TO_CASE_BUTTON,
ATTACH_TIMELINE_TO_EXISTING_CASE_ICON,
ATTACH_TIMELINE_TO_NEW_CASE_ICON,
CASE,
@@ -40,12 +41,14 @@ import {
TIMELINE_FILTER_VALUE,
TIMELINE_INSPECT_BUTTON,
TIMELINE_SETTINGS_ICON,
- TIMELINE_TITLE,
+ TIMELINE_TITLE_INPUT,
TIMELINE_TITLE_BY_ID,
TIMESTAMP_TOGGLE_FIELD,
TOGGLE_TIMELINE_EXPAND_EVENT,
CREATE_NEW_TIMELINE_TEMPLATE,
OPEN_TIMELINE_TEMPLATE_ICON,
+ TIMELINE_EDIT_MODAL_OPEN_BUTTON,
+ TIMELINE_EDIT_MODAL_SAVE_BUTTON,
} from '../screens/timeline';
import { TIMELINES_TABLE } from '../screens/timelines';
@@ -59,8 +62,10 @@ export const addDescriptionToTimeline = (description: string) => {
};
export const addNameToTimeline = (name: string) => {
- cy.get(TIMELINE_TITLE).type(`${name}{enter}`);
- cy.get(TIMELINE_TITLE).should('have.attr', 'value', name);
+ cy.get(TIMELINE_EDIT_MODAL_OPEN_BUTTON).first().click();
+ cy.get(TIMELINE_TITLE_INPUT).type(`${name}{enter}`);
+ cy.get(TIMELINE_TITLE_INPUT).should('have.attr', 'value', name);
+ cy.get(TIMELINE_EDIT_MODAL_SAVE_BUTTON).click();
};
export const addNotesToTimeline = (notes: string) => {
@@ -85,12 +90,12 @@ export const addNewCase = () => {
};
export const attachTimelineToNewCase = () => {
- cy.get(TIMELINE_SETTINGS_ICON).click({ force: true });
+ cy.get(ATTACH_TIMELINE_TO_CASE_BUTTON).click({ force: true });
cy.get(ATTACH_TIMELINE_TO_NEW_CASE_ICON).click({ force: true });
};
export const attachTimelineToExistingCase = () => {
- cy.get(TIMELINE_SETTINGS_ICON).click({ force: true });
+ cy.get(ATTACH_TIMELINE_TO_CASE_BUTTON).click({ force: true });
cy.get(ATTACH_TIMELINE_TO_EXISTING_CASE_ICON).click({ force: true });
};
@@ -107,17 +112,18 @@ export const closeNotes = () => {
};
export const closeTimeline = () => {
- cy.get(CLOSE_TIMELINE_BTN).click({ force: true });
+ cy.get(CLOSE_TIMELINE_BTN).filter(':visible').click({ force: true });
};
export const createNewTimeline = () => {
- cy.get(TIMELINE_SETTINGS_ICON).click({ force: true });
+ cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').click({ force: true });
+ cy.get(CREATE_NEW_TIMELINE).should('be.visible');
cy.get(CREATE_NEW_TIMELINE).click();
- cy.get(CLOSE_TIMELINE_BTN).click({ force: true });
+ cy.get(CLOSE_TIMELINE_BTN).filter(':visible').click({ force: true });
};
export const createNewTimelineTemplate = () => {
- cy.get(TIMELINE_SETTINGS_ICON).click({ force: true });
+ cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').click({ force: true });
cy.get(CREATE_NEW_TIMELINE_TEMPLATE).click();
};
@@ -153,10 +159,6 @@ export const openTimelineTemplateFromSettings = (id: string) => {
cy.get(TIMELINE_TITLE_BY_ID(id)).click({ force: true });
};
-export const openTimelineSettings = () => {
- cy.get(TIMELINE_SETTINGS_ICON).trigger('click', { force: true });
-};
-
export const pinFirstEvent = () => {
cy.get(PIN_EVENT).first().click({ force: true });
};
diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx
index 6573457c5f39ad..3b64c1f7f1f656 100644
--- a/x-pack/plugins/security_solution/public/app/home/index.tsx
+++ b/x-pack/plugins/security_solution/public/app/home/index.tsx
@@ -37,8 +37,6 @@ const Main = styled.main.attrs<{ paddingTop: number }>(({ paddingTop }) => ({
Main.displayName = 'Main';
-const usersViewing = ['elastic']; // TODO: get the users viewing this timeline from Elasticsearch (persistance)
-
interface HomePageProps {
children: React.ReactNode;
}
@@ -89,7 +87,7 @@ const HomePageComponent: React.FC = ({ children }) => {
{indicesExist && showTimeline && (
<>
-
+
>
)}
diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx
index 9f7e2e73c5bbcf..96d118fea1f55d 100644
--- a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx
@@ -3,12 +3,14 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
+
import { Dispatch } from 'react';
-import { Case } from '../../containers/types';
+import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
-import * as i18n from './translations';
+import { CaseStatuses } from '../../../../../case/common/api';
+import { Case } from '../../containers/types';
import { UpdateCase } from '../../containers/use_get_cases';
+import * as i18n from './translations';
interface GetActions {
caseStatus: string;
@@ -29,7 +31,7 @@ export const getActions = ({
type: 'icon',
'data-test-subj': 'action-delete',
},
- caseStatus === 'open'
+ caseStatus === CaseStatuses.open
? {
description: i18n.CLOSE_CASE,
icon: 'folderCheck',
@@ -37,7 +39,7 @@ export const getActions = ({
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
- updateValue: 'closed',
+ updateValue: CaseStatuses.closed,
caseId: theCase.id,
version: theCase.version,
}),
@@ -51,7 +53,7 @@ export const getActions = ({
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
- updateValue: 'open',
+ updateValue: CaseStatuses.open,
caseId: theCase.id,
version: theCase.version,
}),
diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx
index 42b97d5f6130f1..00873a497c9341 100644
--- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx
@@ -3,6 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
+
import React, { useCallback } from 'react';
import {
EuiAvatar,
@@ -16,6 +17,8 @@ import {
} from '@elastic/eui';
import styled from 'styled-components';
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
+
+import { CaseStatuses } from '../../../../../case/common/api';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { Case } from '../../containers/types';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
@@ -59,7 +62,7 @@ export const getCasesColumns = (
) : (
{theCase.title}
);
- return theCase.status === 'open' ? (
+ return theCase.status !== CaseStatuses.closed ? (
caseDetailsLinkComponent
) : (
<>
@@ -127,7 +130,7 @@ export const getCasesColumns = (
? renderStringField(`${totalComment}`, `case-table-column-commentCount`)
: getEmptyTagValue(),
},
- filterStatus === 'open'
+ filterStatus === CaseStatuses.open
? {
field: 'createdAt',
name: i18n.OPENED_ON,
diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx
index e301e80c9561d5..9ea39f5ca99b91 100644
--- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx
@@ -14,6 +14,7 @@ import { TestProviders } from '../../../common/mock';
import { useGetCasesMockState } from '../../containers/mock';
import * as i18n from './translations';
+import { CaseStatuses } from '../../../../../case/common/api';
import { useKibana } from '../../../common/lib/kibana';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { useDeleteCases } from '../../containers/use_delete_cases';
@@ -159,7 +160,7 @@ describe('AllCases', () => {
expect(column.find('span').text()).toEqual(emptyTag);
};
await waitFor(() => {
- getCasesColumns([], 'open', false).map(
+ getCasesColumns([], CaseStatuses.open, false).map(
(i, key) => i.name != null && checkIt(`${i.name}`, key)
);
});
@@ -175,7 +176,9 @@ describe('AllCases', () => {
const checkIt = (columnName: string) => {
expect(columnName).not.toEqual(i18n.ACTIONS);
};
- getCasesColumns([], 'open', true).map((i, key) => i.name != null && checkIt(`${i.name}`));
+ getCasesColumns([], CaseStatuses.open, true).map(
+ (i, key) => i.name != null && checkIt(`${i.name}`)
+ );
expect(wrapper.find(`a[data-test-subj="case-details-link"]`).exists()).toBeFalsy();
});
});
@@ -208,7 +211,7 @@ describe('AllCases', () => {
expect(dispatchUpdateCaseProperty).toBeCalledWith({
caseId: firstCase.id,
updateKey: 'status',
- updateValue: 'closed',
+ updateValue: CaseStatuses.closed,
refetchCasesStatus: fetchCasesStatus,
version: firstCase.version,
});
@@ -217,7 +220,7 @@ describe('AllCases', () => {
it('opens case when row action icon clicked', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
- filterOptions: { ...defaultGetCases.filterOptions, status: 'closed' },
+ filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed },
});
const wrapper = mount(
@@ -231,7 +234,7 @@ describe('AllCases', () => {
expect(dispatchUpdateCaseProperty).toBeCalledWith({
caseId: firstCase.id,
updateKey: 'status',
- updateValue: 'open',
+ updateValue: CaseStatuses.open,
refetchCasesStatus: fetchCasesStatus,
version: firstCase.version,
});
@@ -288,7 +291,7 @@ describe('AllCases', () => {
await waitFor(() => {
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-close-button"]').first().simulate('click');
- expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'closed');
+ expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.closed);
});
});
it('Bulk open status update', async () => {
@@ -297,7 +300,7 @@ describe('AllCases', () => {
selectedCases: useGetCasesMockState.data.cases,
filterOptions: {
...defaultGetCases.filterOptions,
- status: 'closed',
+ status: CaseStatuses.closed,
},
});
@@ -309,7 +312,7 @@ describe('AllCases', () => {
await waitFor(() => {
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-open-button"]').first().simulate('click');
- expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'open');
+ expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.open);
});
});
it('isDeleted is true, refetch', async () => {
diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx
index 42a87de2aa07b9..05bc6d10d22a55 100644
--- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx
@@ -19,6 +19,7 @@ import { isEmpty, memoize } from 'lodash/fp';
import styled, { css } from 'styled-components';
import * as i18n from './translations';
+import { CaseStatuses } from '../../../../../case/common/api';
import { getCasesColumns } from './columns';
import { Case, DeleteCase, FilterOptions, SortFieldCase } from '../../containers/types';
import { useGetCases, UpdateCase } from '../../containers/use_get_cases';
@@ -37,7 +38,6 @@ import { getCreateCaseUrl, useFormatUrl } from '../../../common/components/link_
import { getBulkItems } from '../bulk_actions';
import { CaseHeaderPage } from '../case_header_page';
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
-import { OpenClosedStats } from '../open_closed_stats';
import { getActions } from './actions';
import { CasesTableFilters } from './table_filters';
import { useUpdateCases } from '../../containers/use_bulk_update_case';
@@ -50,6 +50,7 @@ import { LinkButton } from '../../../common/components/links';
import { SecurityPageName } from '../../../app/types';
import { useKibana } from '../../../common/lib/kibana';
import { APP_ID } from '../../../../common/constants';
+import { Stats } from '../status';
const Div = styled.div`
margin-top: ${({ theme }) => theme.eui.paddingSizes.m};
@@ -91,8 +92,9 @@ export const AllCases = React.memo(
const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case);
const { actionLicense } = useGetActionLicense();
const {
- countClosedCases,
countOpenCases,
+ countInProgressCases,
+ countClosedCases,
isLoading: isCasesStatusLoading,
fetchCasesStatus,
} = useGetCasesStatus();
@@ -291,10 +293,15 @@ export const AllCases = React.memo(
const onFilterChangedCallback = useCallback(
(newFilterOptions: Partial) => {
- if (newFilterOptions.status && newFilterOptions.status === 'closed') {
+ if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.closed) {
setQueryParams({ sortField: SortFieldCase.closedAt });
- } else if (newFilterOptions.status && newFilterOptions.status === 'open') {
+ } else if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.open) {
setQueryParams({ sortField: SortFieldCase.createdAt });
+ } else if (
+ newFilterOptions.status &&
+ newFilterOptions.status === CaseStatuses['in-progress']
+ ) {
+ setQueryParams({ sortField: SortFieldCase.updatedAt });
}
setFilters(newFilterOptions);
refreshCases(false);
@@ -375,18 +382,26 @@ export const AllCases = React.memo(
data-test-subj="all-cases-header"
>
-
+
+
+
-
@@ -422,6 +437,7 @@ export const AllCases = React.memo(
;
+ selectedStatus: CaseStatuses;
+ onStatusChanged: (status: CaseStatuses) => void;
+}
+
+const StatusFilterComponent: React.FC = ({ stats, selectedStatus, onStatusChanged }) => {
+ const caseStatuses = Object.keys(statuses) as CaseStatuses[];
+ const options: Array> = caseStatuses.map((status) => ({
+ value: status,
+ inputDisplay: (
+
+
+
+
+ {` (${stats[status]})`}
+
+ ),
+ 'data-test-subj': `case-status-filter-${status}`,
+ }));
+
+ return (
+
+ );
+};
+
+export const StatusFilter = memo(StatusFilterComponent);
diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx
index 9b516f600e9e5b..0c9a725f918e54 100644
--- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx
@@ -7,12 +7,13 @@
import React from 'react';
import { mount } from 'enzyme';
+import { CaseStatuses } from '../../../../../case/common/api';
import { CasesTableFilters } from './table_filters';
import { TestProviders } from '../../../common/mock';
-
import { useGetTags } from '../../containers/use_get_tags';
import { useGetReporters } from '../../containers/use_get_reporters';
import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases';
+
jest.mock('../../containers/use_get_reporters');
jest.mock('../../containers/use_get_tags');
@@ -24,10 +25,12 @@ const setFilterRefetch = jest.fn();
const props = {
countClosedCases: 1234,
countOpenCases: 99,
+ countInProgressCases: 54,
onFilterChanged,
initial: DEFAULT_FILTER_OPTIONS,
setFilterRefetch,
};
+
describe('CasesTableFilters ', () => {
beforeEach(() => {
jest.resetAllMocks();
@@ -40,19 +43,17 @@ describe('CasesTableFilters ', () => {
fetchReporters,
});
});
- it('should render the initial case count', () => {
+
+ it('should render the case status filter dropdown', () => {
const wrapper = mount(
);
- expect(wrapper.find(`[data-test-subj="open-case-count"]`).last().text()).toEqual(
- 'Open cases (99)'
- );
- expect(wrapper.find(`[data-test-subj="closed-case-count"]`).last().text()).toEqual(
- 'Closed cases (1234)'
- );
+
+ expect(wrapper.find(`[data-test-subj="case-status-filter"]`).first().exists()).toBeTruthy();
});
+
it('should call onFilterChange when selected tags change', () => {
const wrapper = mount(
@@ -64,6 +65,7 @@ describe('CasesTableFilters ', () => {
expect(onFilterChanged).toBeCalledWith({ tags: ['coke'] });
});
+
it('should call onFilterChange when selected reporters change', () => {
const wrapper = mount(
@@ -79,6 +81,7 @@ describe('CasesTableFilters ', () => {
expect(onFilterChanged).toBeCalledWith({ reporters: [{ username: 'casetester' }] });
});
+
it('should call onFilterChange when search changes', () => {
const wrapper = mount(
@@ -92,16 +95,19 @@ describe('CasesTableFilters ', () => {
.simulate('keyup', { key: 'Enter', target: { value: 'My search' } });
expect(onFilterChanged).toBeCalledWith({ search: 'My search' });
});
- it('should call onFilterChange when status toggled', () => {
+
+ it('should call onFilterChange when changing status', () => {
const wrapper = mount(
);
- wrapper.find(`[data-test-subj="closed-case-count"]`).last().simulate('click');
- expect(onFilterChanged).toBeCalledWith({ status: 'closed' });
+ wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
+ wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click');
+ expect(onFilterChanged).toBeCalledWith({ status: CaseStatuses.closed });
});
+
it('should call on load setFilterRefetch', () => {
mount(
@@ -110,6 +116,7 @@ describe('CasesTableFilters ', () => {
);
expect(setFilterRefetch).toHaveBeenCalled();
});
+
it('should remove tag from selected tags when tag no longer exists', () => {
const ourProps = {
...props,
@@ -125,6 +132,7 @@ describe('CasesTableFilters ', () => {
);
expect(onFilterChanged).toHaveBeenCalledWith({ tags: ['pepsi'] });
});
+
it('should remove reporter from selected reporters when reporter no longer exists', () => {
const ourProps = {
...props,
diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx
index 63172bd6ad6bbd..f5ec0bf1441540 100644
--- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx
@@ -4,24 +4,21 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { isEqual } from 'lodash/fp';
-import {
- EuiFieldSearch,
- EuiFilterButton,
- EuiFilterGroup,
- EuiFlexGroup,
- EuiFlexItem,
-} from '@elastic/eui';
-import * as i18n from './translations';
+import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup } from '@elastic/eui';
+import { CaseStatuses } from '../../../../../case/common/api';
import { FilterOptions } from '../../containers/types';
import { useGetTags } from '../../containers/use_get_tags';
import { useGetReporters } from '../../containers/use_get_reporters';
import { FilterPopover } from '../filter_popover';
+import { StatusFilter } from './status_filter';
+import * as i18n from './translations';
interface CasesTableFiltersProps {
countClosedCases: number | null;
+ countInProgressCases: number | null;
countOpenCases: number | null;
onFilterChanged: (filterOptions: Partial) => void;
initial: FilterOptions;
@@ -35,11 +32,12 @@ interface CasesTableFiltersProps {
* @param onFilterChanged change listener to be notified on filter changes
*/
-const defaultInitial = { search: '', reporters: [], status: 'open', tags: [] };
+const defaultInitial = { search: '', reporters: [], status: CaseStatuses.open, tags: [] };
const CasesTableFiltersComponent = ({
countClosedCases,
countOpenCases,
+ countInProgressCases,
onFilterChanged,
initial = defaultInitial,
setFilterRefetch,
@@ -49,18 +47,20 @@ const CasesTableFiltersComponent = ({
);
const [search, setSearch] = useState(initial.search);
const [selectedTags, setSelectedTags] = useState(initial.tags);
- const [showOpenCases, setShowOpenCases] = useState(initial.status === 'open');
const { tags, fetchTags } = useGetTags();
const { reporters, respReporters, fetchReporters } = useGetReporters();
+
const refetch = useCallback(() => {
fetchTags();
fetchReporters();
}, [fetchReporters, fetchTags]);
+
useEffect(() => {
if (setFilterRefetch != null) {
setFilterRefetch(refetch);
}
}, [refetch, setFilterRefetch]);
+
useEffect(() => {
if (selectedReporters.length) {
const newReporters = selectedReporters.filter((r) => reporters.includes(r));
@@ -68,6 +68,7 @@ const CasesTableFiltersComponent = ({
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reporters]);
+
useEffect(() => {
if (selectedTags.length) {
const newTags = selectedTags.filter((t) => tags.includes(t));
@@ -100,6 +101,7 @@ const CasesTableFiltersComponent = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedTags]
);
+
const handleOnSearch = useCallback(
(newSearch) => {
const trimSearch = newSearch.trim();
@@ -111,19 +113,26 @@ const CasesTableFiltersComponent = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
[search]
);
- const handleToggleFilter = useCallback(
- (showOpen) => {
- if (showOpen !== showOpenCases) {
- setShowOpenCases(showOpen);
- onFilterChanged({ status: showOpen ? 'open' : 'closed' });
- }
+
+ const onStatusChanged = useCallback(
+ (status: CaseStatuses) => {
+ onFilterChanged({ status });
},
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [showOpenCases]
+ [onFilterChanged]
+ );
+
+ const stats = useMemo(
+ () => ({
+ [CaseStatuses.open]: countOpenCases ?? 0,
+ [CaseStatuses['in-progress']]: countInProgressCases ?? 0,
+ [CaseStatuses.closed]: countClosedCases ?? 0,
+ }),
+ [countClosedCases, countInProgressCases, countOpenCases]
);
+
return (
-
+