Retain APIKey when disabling/enabling a rule (#131581)

* Retain APIKey when disabling/enabling a rule
This commit is contained in:
Ersin Erdal 2022-05-18 15:13:44 +02:00 committed by GitHub
parent d828e9b799
commit de29010c43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 2057 additions and 1165 deletions

View file

@ -65,10 +65,9 @@ Rules and connectors are isolated to the {kib} space in which they were created.
Rules are authorized using an <<api-keys,API key>> associated with the last user to edit the rule. This API key captures a snapshot of the user's privileges at the time of edit and is subsequently used to run all background tasks associated with the rule, including condition checks like {es} queries and triggered actions. The following rule actions will re-generate the API key:
* Creating a rule
* Enabling a disabled rule
* Updating a rule
[IMPORTANT]
==============================================
If a rule requires certain privileges, such as index privileges, to run, and a user without those privileges updates, disables, or re-enables the rule, the rule will no longer function. Conversely, if a user with greater or administrator privileges modifies the rule, it will begin running with increased privileges.
If a rule requires certain privileges, such as index privileges, to run, and a user without those privileges updates the rule, the rule will no longer function. Conversely, if a user with greater or administrator privileges modifies the rule, it will begin running with increased privileges.
==============================================

View file

@ -1828,7 +1828,7 @@ export class RulesClient {
}
private async enableWithOCC({ id }: { id: string }) {
let apiKeyToInvalidate: string | null = null;
let existingApiKey: string | null = null;
let attributes: RawRule;
let version: string | undefined;
@ -1837,14 +1837,11 @@ export class RulesClient {
await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser<RawRule>('alert', id, {
namespace: this.namespace,
});
apiKeyToInvalidate = decryptedAlert.attributes.apiKey;
existingApiKey = decryptedAlert.attributes.apiKey;
attributes = decryptedAlert.attributes;
version = decryptedAlert.version;
} catch (e) {
// We'll skip invalidating the API key since we failed to load the decrypted saved object
this.logger.error(
`enable(): Failed to load API key to invalidate on alert ${id}: ${e.message}`
);
this.logger.error(`enable(): Failed to load API key of alert ${id}: ${e.message}`);
// Still attempt to load the attributes and version using SOC
const alert = await this.unsecuredSavedObjectsClient.get<RawRule>('alert', id);
attributes = alert.attributes;
@ -1886,19 +1883,10 @@ export class RulesClient {
if (attributes.enabled === false) {
const username = await this.getUserName();
let createdAPIKey = null;
try {
createdAPIKey = await this.createAPIKey(
this.generateAPIKeyName(attributes.alertTypeId, attributes.name)
);
} catch (error) {
throw Boom.badRequest(`Error enabling rule: could not create API key - ${error.message}`);
}
const updateAttributes = this.updateMeta({
...attributes,
...(!existingApiKey && (await this.createNewAPIKeySet({ attributes, username }))),
enabled: true,
...this.apiKeyAsAlertAttributes(createdAPIKey, username),
updatedBy: username,
updatedAt: new Date().toISOString(),
executionStatus: {
@ -1909,15 +1897,10 @@ export class RulesClient {
warning: null,
},
});
try {
await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version });
} catch (e) {
// Avoid unused API key
await bulkMarkApiKeysForInvalidation(
{ apiKeys: updateAttributes.apiKey ? [updateAttributes.apiKey] : [] },
this.logger,
this.unsecuredSavedObjectsClient
);
throw e;
}
const scheduledTask = await this.scheduleRule({
@ -1930,16 +1913,28 @@ export class RulesClient {
await this.unsecuredSavedObjectsClient.update('alert', id, {
scheduledTaskId: scheduledTask.id,
});
if (apiKeyToInvalidate) {
await bulkMarkApiKeysForInvalidation(
{ apiKeys: [apiKeyToInvalidate] },
this.logger,
this.unsecuredSavedObjectsClient
);
}
}
}
private async createNewAPIKeySet({
attributes,
username,
}: {
attributes: RawRule;
username: string | null;
}): Promise<Pick<RawRule, 'apiKey' | 'apiKeyOwner'>> {
let createdAPIKey = null;
try {
createdAPIKey = await this.createAPIKey(
this.generateAPIKeyName(attributes.alertTypeId, attributes.name)
);
} catch (error) {
throw Boom.badRequest(`Error creating API key for rule: ${error.message}`);
}
return this.apiKeyAsAlertAttributes(createdAPIKey, username);
}
public async disable({ id }: { id: string }): Promise<void> {
return await retryIfConflicts(
this.logger,
@ -1949,7 +1944,6 @@ export class RulesClient {
}
private async disableWithOCC({ id }: { id: string }) {
let apiKeyToInvalidate: string | null = null;
let attributes: RawRule;
let version: string | undefined;
@ -1958,14 +1952,10 @@ export class RulesClient {
await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser<RawRule>('alert', id, {
namespace: this.namespace,
});
apiKeyToInvalidate = decryptedAlert.attributes.apiKey;
attributes = decryptedAlert.attributes;
version = decryptedAlert.version;
} catch (e) {
// We'll skip invalidating the API key since we failed to load the decrypted saved object
this.logger.error(
`disable(): Failed to load API key to invalidate on alert ${id}: ${e.message}`
);
this.logger.error(`disable(): Failed to load API key of alert ${id}: ${e.message}`);
// Still attempt to load the attributes and version using SOC
const alert = await this.unsecuredSavedObjectsClient.get<RawRule>('alert', id);
attributes = alert.attributes;
@ -2058,26 +2048,14 @@ export class RulesClient {
...attributes,
enabled: false,
scheduledTaskId: null,
apiKey: null,
apiKeyOwner: null,
updatedBy: await this.getUserName(),
updatedAt: new Date().toISOString(),
}),
{ version }
);
await Promise.all([
attributes.scheduledTaskId
? this.taskManager.removeIfExists(attributes.scheduledTaskId)
: null,
apiKeyToInvalidate
? await bulkMarkApiKeysForInvalidation(
{ apiKeys: [apiKeyToInvalidate] },
this.logger,
this.unsecuredSavedObjectsClient
)
: null,
]);
if (attributes.scheduledTaskId) {
await this.taskManager.removeIfExists(attributes.scheduledTaskId);
}
}
}

View file

@ -18,7 +18,6 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup, setGlobalDate } from './lib';
import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock';
import { TaskStatus } from '@kbn/task-manager-plugin/server';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
@ -111,6 +110,7 @@ describe('disable()', () => {
attributes: {
...existingAlert.attributes,
apiKey: Buffer.from('123:abc').toString('base64'),
apiKeyOwner: 'elastic',
},
version: '123',
references: [],
@ -206,11 +206,11 @@ describe('disable()', () => {
alertTypeId: 'myType',
enabled: false,
meta: {
versionApiKeyLastmodified: kibanaVersion,
versionApiKeyLastmodified: 'v7.10.0',
},
scheduledTaskId: null,
apiKey: null,
apiKeyOwner: null,
apiKey: 'MTIzOmFiYw==',
apiKeyOwner: 'elastic',
updatedAt: '2019-02-12T21:01:22.479Z',
updatedBy: 'elastic',
actions: [
@ -230,12 +230,6 @@ describe('disable()', () => {
}
);
expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123');
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});
test('disables the rule with calling event log to "recover" the alert instances from the task state', async () => {
@ -282,11 +276,11 @@ describe('disable()', () => {
alertTypeId: 'myType',
enabled: false,
meta: {
versionApiKeyLastmodified: kibanaVersion,
versionApiKeyLastmodified: 'v7.10.0',
},
scheduledTaskId: null,
apiKey: null,
apiKeyOwner: null,
apiKey: 'MTIzOmFiYw==',
apiKeyOwner: 'elastic',
updatedAt: '2019-02-12T21:01:22.479Z',
updatedBy: 'elastic',
actions: [
@ -306,12 +300,6 @@ describe('disable()', () => {
}
);
expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123');
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
expect(eventLogger.logEvent).toHaveBeenCalledTimes(1);
expect(eventLogger.logEvent.mock.calls[0][0]).toStrictEqual({
@ -369,11 +357,11 @@ describe('disable()', () => {
alertTypeId: 'myType',
enabled: false,
meta: {
versionApiKeyLastmodified: kibanaVersion,
versionApiKeyLastmodified: 'v7.10.0',
},
scheduledTaskId: null,
apiKey: null,
apiKeyOwner: null,
apiKey: 'MTIzOmFiYw==',
apiKeyOwner: 'elastic',
updatedAt: '2019-02-12T21:01:22.479Z',
updatedBy: 'elastic',
actions: [
@ -393,12 +381,6 @@ describe('disable()', () => {
}
);
expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123');
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
expect(eventLogger.logEvent).toHaveBeenCalledTimes(0);
expect(rulesClientParams.logger.warn).toHaveBeenCalledWith(
@ -408,7 +390,6 @@ describe('disable()', () => {
test('falls back when getDecryptedAsInternalUser throws an error', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail'));
await rulesClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1');
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
@ -422,12 +403,7 @@ describe('disable()', () => {
schedule: { interval: '10s' },
alertTypeId: 'myType',
enabled: false,
meta: {
versionApiKeyLastmodified: kibanaVersion,
},
scheduledTaskId: null,
apiKey: null,
apiKeyOwner: null,
updatedAt: '2019-02-12T21:01:22.479Z',
updatedBy: 'elastic',
actions: [
@ -447,7 +423,6 @@ describe('disable()', () => {
}
);
expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123');
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
});
test(`doesn't disable already disabled alerts`, async () => {
@ -463,14 +438,6 @@ describe('disable()', () => {
await rulesClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled();
expect(taskManager.removeIfExists).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
});
test(`doesn't invalidate when no API key is used`, async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce(existingAlert);
await rulesClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
});
test('swallows error when failing to load decrypted saved object', async () => {
@ -479,9 +446,8 @@ describe('disable()', () => {
await rulesClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled();
expect(taskManager.removeIfExists).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
expect(rulesClientParams.logger.error).toHaveBeenCalledWith(
'disable(): Failed to load API key to invalidate on alert 1: Fail'
'disable(): Failed to load API key of alert 1: Fail'
);
});
@ -493,17 +459,6 @@ describe('disable()', () => {
);
});
test('swallows error when invalidate API key throws', async () => {
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail'));
await rulesClient.disable({ id: '1' });
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});
test('throws when failing to remove task from task manager', async () => {
taskManager.removeIfExists.mockRejectedValueOnce(new Error('Failed to remove task'));

View file

@ -17,7 +17,6 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { TaskStatus } from '@kbn/task-manager-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup, setGlobalDate } from './lib';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
@ -51,23 +50,22 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
auditLogger,
};
beforeEach(() => {
getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry);
(auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
describe('enable()', () => {
let rulesClient: RulesClient;
const existingAlert = {
const existingRule = {
id: '1',
type: 'alert',
attributes: {
name: 'name',
consumer: 'myApp',
schedule: { interval: '10s' },
alertTypeId: 'myType',
enabled: false,
apiKey: 'MTIzOmFiYw==',
apiKeyOwner: 'elastic',
actions: [
{
group: 'default',
@ -84,23 +82,24 @@ describe('enable()', () => {
references: [],
};
const existingRuleWithoutApiKey = {
...existingRule,
attributes: {
...existingRule.attributes,
apiKey: null,
apiKeyOwner: null,
},
};
beforeEach(() => {
getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry);
(auditLogger.log as jest.Mock).mockClear();
rulesClient = new RulesClient(rulesClientParams);
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingAlert);
unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert);
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRule);
unsecuredSavedObjectsClient.get.mockResolvedValue(existingRule);
rulesClientParams.createAPIKey.mockResolvedValue({
apiKeysEnabled: false,
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
...existingAlert,
attributes: {
...existingAlert.attributes,
enabled: true,
apiKey: null,
apiKeyOwner: null,
updatedBy: 'elastic',
},
});
taskManager.schedule.mockResolvedValue({
id: '1',
scheduledAt: new Date(),
@ -187,27 +186,17 @@ describe('enable()', () => {
});
test('enables a rule', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
...existingAlert,
attributes: {
...existingAlert.attributes,
enabled: true,
apiKey: null,
apiKeyOwner: null,
updatedBy: 'elastic',
},
});
await rulesClient.enable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
namespace: 'default',
});
expect(rulesClientParams.createAPIKey).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation');
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith(
'alert',
'1',
{
name: 'name',
schedule: { interval: '10s' },
alertTypeId: 'myType',
consumer: 'myApp',
@ -217,8 +206,8 @@ describe('enable()', () => {
},
updatedAt: '2019-02-12T21:01:22.479Z',
updatedBy: 'elastic',
apiKey: null,
apiKeyOwner: null,
apiKey: 'MTIzOmFiYw==',
apiKeyOwner: 'elastic',
actions: [
{
group: 'default',
@ -265,33 +254,65 @@ describe('enable()', () => {
});
});
test('invalidates API key if ever one existed prior to updating', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({
...existingAlert,
attributes: {
...existingAlert.attributes,
apiKey: Buffer.from('123:abc').toString('base64'),
},
test('enables a rule that does not have an apiKey', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRuleWithoutApiKey);
rulesClientParams.createAPIKey.mockResolvedValueOnce({
apiKeysEnabled: true,
result: { id: '123', name: '123', api_key: 'abc' },
});
await rulesClient.enable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
namespace: 'default',
});
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation');
expect(rulesClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: myType/name');
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith(
'alert',
'1',
{
name: 'name',
schedule: { interval: '10s' },
alertTypeId: 'myType',
consumer: 'myApp',
enabled: true,
meta: {
versionApiKeyLastmodified: kibanaVersion,
},
updatedAt: '2019-02-12T21:01:22.479Z',
updatedBy: 'elastic',
apiKey: 'MTIzOmFiYw==',
apiKeyOwner: 'elastic',
actions: [
{
group: 'default',
id: '1',
actionTypeId: '1',
actionRef: '1',
params: {
foo: true,
},
},
],
executionStatus: {
status: 'pending',
lastDuration: 0,
lastExecutionDate: '2019-02-12T21:01:22.479Z',
error: null,
warning: null,
},
},
{
version: '123',
}
);
});
test(`doesn't enable already enabled alerts`, async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce({
...existingAlert,
...existingRuleWithoutApiKey,
attributes: {
...existingAlert.attributes,
...existingRuleWithoutApiKey.attributes,
enabled: true,
},
});
@ -314,6 +335,7 @@ describe('enable()', () => {
'alert',
'1',
{
name: 'name',
schedule: { interval: '10s' },
alertTypeId: 'myType',
consumer: 'myApp',
@ -351,14 +373,14 @@ describe('enable()', () => {
});
test('throws an error if API key creation throws', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRuleWithoutApiKey);
rulesClientParams.createAPIKey.mockImplementation(() => {
throw new Error('no');
});
expect(
await expect(
async () => await rulesClient.enable({ id: '1' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Error enabling rule: could not create API key - no"`
);
).rejects.toThrowErrorMatchingInlineSnapshot(`"Error creating API key for rule: no"`);
});
test('falls back when failing to getDecryptedAsInternalUser', async () => {
@ -367,7 +389,7 @@ describe('enable()', () => {
await rulesClient.enable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1');
expect(rulesClientParams.logger.error).toHaveBeenCalledWith(
'enable(): Failed to load API key to invalidate on alert 1: Fail'
'enable(): Failed to load API key of alert 1: Fail'
);
});
@ -396,13 +418,6 @@ describe('enable()', () => {
`"Fail to update"`
);
expect(rulesClientParams.getUserName).toHaveBeenCalled();
expect(rulesClientParams.createAPIKey).toHaveBeenCalled();
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1);
expect(taskManager.schedule).not.toHaveBeenCalled();
});
@ -410,9 +425,9 @@ describe('enable()', () => {
test('throws error when failing to update the second time', async () => {
unsecuredSavedObjectsClient.update.mockReset();
unsecuredSavedObjectsClient.update.mockResolvedValueOnce({
...existingAlert,
...existingRuleWithoutApiKey,
attributes: {
...existingAlert.attributes,
...existingRuleWithoutApiKey.attributes,
enabled: true,
},
});
@ -424,7 +439,6 @@ describe('enable()', () => {
`"Fail to update second time"`
);
expect(rulesClientParams.getUserName).toHaveBeenCalled();
expect(rulesClientParams.createAPIKey).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(2);
expect(taskManager.schedule).toHaveBeenCalled();
});
@ -436,15 +450,14 @@ describe('enable()', () => {
`"Fail to schedule"`
);
expect(rulesClientParams.getUserName).toHaveBeenCalled();
expect(rulesClientParams.createAPIKey).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled();
});
test('enables a rule if conflict errors received when scheduling a task', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
...existingAlert,
...existingRuleWithoutApiKey,
attributes: {
...existingAlert.attributes,
...existingRuleWithoutApiKey.attributes,
enabled: true,
apiKey: null,
apiKeyOwner: null,
@ -460,11 +473,12 @@ describe('enable()', () => {
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
namespace: 'default',
});
expect(rulesClientParams.createAPIKey).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation');
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith(
'alert',
'1',
{
name: 'name',
schedule: { interval: '10s' },
alertTypeId: 'myType',
consumer: 'myApp',
@ -474,8 +488,8 @@ describe('enable()', () => {
},
updatedAt: '2019-02-12T21:01:22.479Z',
updatedBy: 'elastic',
apiKey: null,
apiKeyOwner: null,
apiKey: 'MTIzOmFiYw==',
apiKeyOwner: 'elastic',
actions: [
{
group: 'default',

View file

@ -54,7 +54,7 @@ export const DeleteModalConfirmation = ({
'xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.descriptionText',
{
defaultMessage:
"You can't recover {numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}.",
"You won't be able to recover {numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}.",
values: { numIdsToDelete, singleTitle, multipleTitle },
}
);

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiConfirmModal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import { HttpSetup } from '@kbn/core/public';
import { useKibana } from '../../common/lib/kibana';
export const UpdateApiKeyModalConfirmation = ({
onCancel,
idsToUpdate,
apiUpdateApiKeyCall,
setIsLoadingState,
onUpdated,
}: {
onCancel: () => void;
idsToUpdate: string[];
apiUpdateApiKeyCall: ({ id, http }: { id: string; http: HttpSetup }) => Promise<string>;
setIsLoadingState: (isLoading: boolean) => void;
onUpdated: () => void;
}) => {
const {
http,
notifications: { toasts },
} = useKibana().services;
const [updateModalFlyoutVisible, setUpdateModalVisibility] = useState<boolean>(false);
useEffect(() => {
setUpdateModalVisibility(idsToUpdate.length > 0);
}, [idsToUpdate]);
return updateModalFlyoutVisible ? (
<EuiConfirmModal
buttonColor="primary"
data-test-subj="updateApiKeyIdsConfirmation"
title={i18n.translate('xpack.triggersActionsUI.updateApiKeyConfirmModal.title', {
defaultMessage: 'Update API key',
})}
onCancel={() => {
setUpdateModalVisibility(false);
onCancel();
}}
onConfirm={async () => {
setUpdateModalVisibility(false);
setIsLoadingState(true);
try {
await Promise.all(idsToUpdate.map((id) => apiUpdateApiKeyCall({ id, http })));
toasts.addSuccess(
i18n.translate('xpack.triggersActionsUI.updateApiKeyConfirmModal.successMessage', {
defaultMessage:
'API {idsToUpdate, plural, one {key} other {keys}} {idsToUpdate, plural, one {has} other {have}} been updated',
values: { idsToUpdate: idsToUpdate.length },
})
);
} catch (e) {
toasts.addError(e, {
title: i18n.translate(
'xpack.triggersActionsUI.updateApiKeyConfirmModal.failureMessage',
{
defaultMessage:
'Failed to update the API {idsToUpdate, plural, one {key} other {keys}}',
values: { idsToUpdate: idsToUpdate.length },
}
),
});
}
setIsLoadingState(false);
onUpdated();
}}
cancelButtonText={i18n.translate(
'xpack.triggersActionsUI.updateApiKeyConfirmModal.cancelButton',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={i18n.translate(
'xpack.triggersActionsUI.updateApiKeyConfirmModal.confirmButton',
{
defaultMessage: 'Update',
}
)}
>
{i18n.translate('xpack.triggersActionsUI.updateApiKeyConfirmModal.description', {
defaultMessage:
'You will not be able to recover the old API {idsToUpdate, plural, one {key} other {keys}}',
values: { idsToUpdate: idsToUpdate.length },
})}
</EuiConfirmModal>
) : null;
};

View file

@ -27,3 +27,4 @@ export { updateRule } from './update';
export { resolveRule } from './resolve_rule';
export { snoozeRule } from './snooze';
export { unsnoozeRule } from './unsnooze';
export { updateAPIKey } from './update_api_key';

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { updateAPIKey } from './update_api_key';
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('updateAPIKey', () => {
test('should call _update_api_key rule API', async () => {
const result = await updateAPIKey({ http, id: '1/' });
expect(result).toEqual(undefined);
expect(http.post.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/internal/alerting/rule/1%2F/_update_api_key",
],
]
`);
});
});

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { HttpSetup } from '@kbn/core/public';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
export async function updateAPIKey({ id, http }: { id: string; http: HttpSetup }): Promise<string> {
return http.post<string>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_update_api_key`
);
}

View file

@ -0,0 +1,3 @@
.ruleActionsPopover__deleteButton {
color: $euiColorDangerText;
}

View file

@ -0,0 +1,201 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import * as React from 'react';
import { RuleActionsPopover } from './rule_actions_popover';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { Rule } from '../../../..';
describe('rule_actions_popover', () => {
const onDeleteMock = jest.fn();
const onApiKeyUpdateMock = jest.fn();
const onEnableDisableMock = jest.fn();
function mockRule(overloads: Partial<Rule> = {}): Rule {
return {
id: '12345',
enabled: true,
name: `rule-12345`,
tags: [],
ruleTypeId: '.noop',
consumer: 'consumer',
schedule: { interval: '1m' },
actions: [],
params: {},
createdBy: null,
updatedBy: null,
createdAt: new Date(),
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
...overloads,
};
}
it('renders all the buttons', () => {
const rule = mockRule();
render(
<IntlProvider locale="en">
<RuleActionsPopover
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
/>
</IntlProvider>
);
const actionButton = screen.getByTestId('ruleActionsButton');
expect(actionButton).toBeInTheDocument();
fireEvent.click(actionButton);
expect(screen.getByText('Update API key')).toBeInTheDocument();
expect(screen.getByText('Delete rule')).toBeInTheDocument();
expect(screen.getByText('Disable')).toBeInTheDocument();
});
it('calls onDelete', async () => {
const rule = mockRule();
render(
<IntlProvider locale="en">
<RuleActionsPopover
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
/>
</IntlProvider>
);
const actionButton = screen.getByTestId('ruleActionsButton');
expect(actionButton).toBeInTheDocument();
fireEvent.click(actionButton);
const deleteButton = screen.getByText('Delete rule');
expect(deleteButton).toBeInTheDocument();
fireEvent.click(deleteButton);
expect(onDeleteMock).toHaveBeenCalledWith('12345');
await waitFor(() => {
expect(screen.queryByText('Delete rule')).not.toBeInTheDocument();
});
});
it('disables the rule', async () => {
const rule = mockRule();
render(
<IntlProvider locale="en">
<RuleActionsPopover
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
/>
</IntlProvider>
);
const actionButton = screen.getByTestId('ruleActionsButton');
expect(actionButton).toBeInTheDocument();
fireEvent.click(actionButton);
const disableButton = screen.getByText('Disable');
expect(disableButton).toBeInTheDocument();
fireEvent.click(disableButton);
expect(onEnableDisableMock).toHaveBeenCalledWith(false);
await waitFor(() => {
expect(screen.queryByText('Disable')).not.toBeInTheDocument();
});
});
it('enables the rule', async () => {
const rule = mockRule({ enabled: false });
render(
<IntlProvider locale="en">
<RuleActionsPopover
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
/>
</IntlProvider>
);
const actionButton = screen.getByTestId('ruleActionsButton');
expect(actionButton).toBeInTheDocument();
fireEvent.click(actionButton);
const enableButton = screen.getByText('Enable');
expect(enableButton).toBeInTheDocument();
fireEvent.click(enableButton);
expect(onEnableDisableMock).toHaveBeenCalledWith(true);
await waitFor(() => {
expect(screen.queryByText('Disable')).not.toBeInTheDocument();
});
});
it('calls onApiKeyUpdate', async () => {
const rule = mockRule();
render(
<IntlProvider locale="en">
<RuleActionsPopover
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
/>
</IntlProvider>
);
const actionButton = screen.getByTestId('ruleActionsButton');
expect(actionButton).toBeInTheDocument();
fireEvent.click(actionButton);
const deleteButton = screen.getByText('Update API key');
expect(deleteButton).toBeInTheDocument();
fireEvent.click(deleteButton);
expect(onApiKeyUpdateMock).toHaveBeenCalledWith('12345');
await waitFor(() => {
expect(screen.queryByText('Update API key')).not.toBeInTheDocument();
});
});
it('disables buttons when the user does not have enough permission', async () => {
const rule = mockRule();
render(
<IntlProvider locale="en">
<RuleActionsPopover
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={false}
onEnableDisable={onEnableDisableMock}
/>
</IntlProvider>
);
const actionButton = screen.getByTestId('ruleActionsButton');
expect(actionButton).toBeInTheDocument();
fireEvent.click(actionButton);
expect(screen.getByText('Delete rule').closest('button')).toBeDisabled();
expect(screen.getByText('Update API key').closest('button')).toBeDisabled();
expect(screen.getByText('Disable').closest('button')).toBeDisabled();
});
});

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { EuiButtonEmpty, EuiContextMenu, EuiPopover } from '@elastic/eui';
import './rule_actions_popopver.scss';
import { Rule } from '../../../..';
export interface RuleActionsPopoverProps {
rule: Rule;
canSaveRule: boolean;
onDelete: (ruleId: string) => void;
onApiKeyUpdate: (ruleId: string) => void;
onEnableDisable: (enable: boolean) => void;
}
export const RuleActionsPopover: React.FunctionComponent<RuleActionsPopoverProps> = ({
rule,
canSaveRule,
onDelete,
onApiKeyUpdate,
onEnableDisable,
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
return (
<EuiPopover
button={
<EuiButtonEmpty
disabled={false}
data-test-subj="ruleActionsButton"
data-testid="ruleActionsButton"
iconType="boxesHorizontal"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.popoverButtonTitle',
{ defaultMessage: 'Actions' }
)}
/>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
ownFocus
panelPaddingSize="none"
>
<EuiContextMenu
initialPanelId={0}
panels={[
{
id: 0,
items: [
{
disabled: !canSaveRule,
'data-test-subj': 'disableButton',
onClick: async () => {
setIsPopoverOpen(false);
onEnableDisable(!rule.enabled);
},
name: !rule.enabled
? i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.enableRuleButtonLabel',
{ defaultMessage: 'Enable' }
)
: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.disableRuleButtonLabel',
{ defaultMessage: 'Disable' }
),
},
{
disabled: !canSaveRule,
'data-test-subj': 'updateAPIKeyButton',
onClick: () => {
setIsPopoverOpen(false);
onApiKeyUpdate(rule.id);
},
name: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.updateAPIKeyButtonLabel',
{ defaultMessage: 'Update API key' }
),
},
{
disabled: !canSaveRule,
className: 'ruleActionsPopover__deleteButton',
'data-test-subj': 'deleteRuleButton',
onClick: () => {
setIsPopoverOpen(false);
onDelete(rule.id);
},
name: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.deleteRuleButtonLabel',
{ defaultMessage: 'Delete rule' }
),
},
],
},
]}
className="ruleActionsPopover"
data-test-subj="ruleActionsPopover"
data-testid="ruleActionsPopover"
/>
</EuiPopover>
);
};

View file

@ -27,6 +27,10 @@ import {
import { FormattedMessage } from '@kbn/i18n-react';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { RuleExecutionStatusErrorReasons, parseDuration } from '@kbn/alerting-plugin/common';
import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation';
import { updateAPIKey, deleteRules } from '../../../lib/rule_api';
import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation';
import { RuleActionsPopover } from './rule_actions_popover';
import {
hasAllPrivilege,
hasExecuteActionsCapability,
@ -49,7 +53,7 @@ import {
import { RuleRouteWithApi } from './rule_route';
import { ViewInApp } from './view_in_app';
import { RuleEdit } from '../../rule_form';
import { routeToRuleDetails } from '../../../constants';
import { routeToRuleDetails, routeToRules } from '../../../constants';
import {
rulesErrorReasonTranslationsMapping,
rulesWarningReasonTranslationsMapping,
@ -94,6 +98,9 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
dispatch({ command: { type: 'setRule' }, payload: { key: 'rule', value } });
};
const [rulesToDelete, setRulesToDelete] = useState<string[]>([]);
const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState<string[]>([]);
const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] =
useState<boolean>(false);
@ -207,6 +214,10 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
history.push(routeToRuleDetails.replace(`:ruleId`, rule.id));
};
const goToRulesList = () => {
history.push(routeToRules);
};
const getRuleStatusErrorReasonText = () => {
if (rule.executionStatus.error && rule.executionStatus.error.reason) {
return rulesErrorReasonTranslationsMapping[rule.executionStatus.error.reason];
@ -223,40 +234,71 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
}
};
const rightPageHeaderButtons = hasEditButton
? [
<>
<EuiButtonEmpty
data-test-subj="openEditRuleFlyoutButton"
iconType="pencil"
onClick={() => setEditFlyoutVisibility(true)}
name="edit"
disabled={!ruleType.enabledInLicense}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.editRuleButtonLabel"
defaultMessage="Edit"
/>
</EuiButtonEmpty>
{editFlyoutVisible && (
<RuleEdit
initialRule={rule}
onClose={() => {
setInitialRule(rule);
setEditFlyoutVisibility(false);
}}
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
ruleType={ruleType}
onSave={setRule}
/>
)}
</>,
]
: [];
const editButton = hasEditButton ? (
<>
<EuiButtonEmpty
data-test-subj="openEditRuleFlyoutButton"
iconType="pencil"
onClick={() => setEditFlyoutVisibility(true)}
name="edit"
disabled={!ruleType.enabledInLicense}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.editRuleButtonLabel"
defaultMessage="Edit"
/>
</EuiButtonEmpty>
{editFlyoutVisible && (
<RuleEdit
initialRule={rule}
onClose={() => {
setInitialRule(rule);
setEditFlyoutVisibility(false);
}}
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
ruleType={ruleType}
onSave={setRule}
/>
)}
</>
) : null;
return (
<>
<DeleteModalConfirmation
onDeleted={async () => {
setRulesToDelete([]);
goToRulesList();
}}
onErrors={async () => {
// Refresh the rule from the server, it may have been deleted
await requestRefresh();
setRulesToDelete([]);
}}
onCancel={() => {
setRulesToDelete([]);
}}
apiDeleteCall={deleteRules}
idsToDelete={rulesToDelete}
singleTitle={i18n.translate('xpack.triggersActionsUI.sections.rulesList.singleTitle', {
defaultMessage: 'rule',
})}
multipleTitle=""
setIsLoadingState={() => {}}
/>
<UpdateApiKeyModalConfirmation
onCancel={() => {
setRulesToUpdateAPIKey([]);
}}
idsToUpdate={rulesToUpdateAPIKey}
apiUpdateApiKeyCall={updateAPIKey}
setIsLoadingState={() => {}}
onUpdated={async () => {
setRulesToUpdateAPIKey([]);
requestRefresh();
}}
/>
<EuiPageHeader
data-test-subj="ruleDetailsTitle"
bottomBorder
@ -378,7 +420,25 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
</EuiFlexGroup>
}
rightSideItems={[
<ViewInApp rule={rule} />,
<RuleActionsPopover
canSaveRule={canSaveRule}
rule={rule}
onDelete={(ruleId) => {
setRulesToDelete([ruleId]);
}}
onApiKeyUpdate={(ruleId) => {
setRulesToUpdateAPIKey([ruleId]);
}}
onEnableDisable={async (enable) => {
if (enable) {
await enableRule(rule);
} else {
await disableRule(rule);
}
requestRefresh();
}}
/>,
editButton,
<EuiButtonEmpty
data-test-subj="refreshRulesButton"
iconType="refresh"
@ -391,7 +451,7 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
defaultMessage="Refresh"
/>
</EuiButtonEmpty>,
...rightPageHeaderButtons,
<ViewInApp rule={rule} />,
]}
/>
<EuiSpacer size="l" />
@ -422,7 +482,6 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
</EuiFlexItem>
</EuiFlexGroup>
) : null}
{rule.enabled && rule.executionStatus.status === 'warning' ? (
<EuiFlexGroup>
<EuiFlexItem>

View file

@ -1,9 +1,3 @@
.actCollapsedItemActions {
.euiContextMenuItem:hover {
text-decoration: none;
}
}
button[data-test-subj='deleteRule'] {
color: $euiColorDanger;
.collapsedItemActions__deleteButton {
color: $euiColorDangerText;
}

View file

@ -21,6 +21,7 @@ const disableRule = jest.fn();
const enableRule = jest.fn();
const unmuteRule = jest.fn();
const muteRule = jest.fn();
const onUpdateAPIKey = jest.fn();
export const tick = (ms = 0) =>
new Promise((resolve) => {
@ -91,6 +92,7 @@ describe('CollapsedItemActions', () => {
enableRule,
unmuteRule,
muteRule,
onUpdateAPIKey,
};
};
@ -118,6 +120,7 @@ describe('CollapsedItemActions', () => {
expect(wrapper.find('[data-test-subj="disableButton"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="editRule"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="deleteRule"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="updateApiKey"]').exists()).toBeFalsy();
wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click');
await act(async () => {
@ -130,6 +133,7 @@ describe('CollapsedItemActions', () => {
expect(wrapper.find('[data-test-subj="disableButton"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="editRule"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="deleteRule"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="updateApiKey"]').exists()).toBeTruthy();
expect(
wrapper.find('[data-test-subj="selectActionButton"]').first().props().disabled
@ -143,6 +147,7 @@ describe('CollapsedItemActions', () => {
expect(wrapper.find(`[data-test-subj="editRule"] button`).text()).toEqual('Edit rule');
expect(wrapper.find(`[data-test-subj="deleteRule"] button`).prop('disabled')).toBeFalsy();
expect(wrapper.find(`[data-test-subj="deleteRule"] button`).text()).toEqual('Delete rule');
expect(wrapper.find(`[data-test-subj="updateApiKey"] button`).text()).toEqual('Update API key');
});
test('handles case when rule is unmuted and enabled and mute is clicked', async () => {

View file

@ -23,6 +23,7 @@ export type ComponentOpts = {
onRuleChanged: () => void;
setRulesToDelete: React.Dispatch<React.SetStateAction<string[]>>;
onEditRule: (item: RuleTableItem) => void;
onUpdateAPIKey: (id: string[]) => void;
} & Pick<BulkOperationsComponentOpts, 'disableRule' | 'enableRule' | 'unmuteRule' | 'muteRule'>;
export const CollapsedItemActions: React.FunctionComponent<ComponentOpts> = ({
@ -34,6 +35,7 @@ export const CollapsedItemActions: React.FunctionComponent<ComponentOpts> = ({
muteRule,
setRulesToDelete,
onEditRule,
onUpdateAPIKey,
}: ComponentOpts) => {
const { ruleTypeRegistry } = useKibana().services;
@ -53,6 +55,7 @@ export const CollapsedItemActions: React.FunctionComponent<ComponentOpts> = ({
<EuiButtonIcon
disabled={!item.isEditable}
data-test-subj="selectActionButton"
data-testid="selectActionButton"
iconType="boxesHorizontal"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
aria-label={i18n.translate(
@ -133,6 +136,19 @@ export const CollapsedItemActions: React.FunctionComponent<ComponentOpts> = ({
},
{
disabled: !item.isEditable,
'data-test-subj': 'updateApiKey',
onClick: () => {
setIsPopoverOpen(!isPopoverOpen);
onUpdateAPIKey([item.id]);
},
name: i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.collapsedItemActions.updateApiKey',
{ defaultMessage: 'Update API key' }
),
},
{
disabled: !item.isEditable,
className: 'collapsedItemActions__deleteButton',
'data-test-subj': 'deleteRule',
onClick: () => {
setIsPopoverOpen(!isPopoverOpen);
@ -161,6 +177,7 @@ export const CollapsedItemActions: React.FunctionComponent<ComponentOpts> = ({
panels={panels}
className="actCollapsedItemActions"
data-test-subj="collapsedActionPanel"
data-testid="collapsedActionPanel"
/>
</EuiPopover>
);

View file

@ -6,9 +6,9 @@
*/
import * as React from 'react';
import { fireEvent, act, render, screen } from '@testing-library/react';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock';
import { RulesList, percentileFields } from './rules_list';
@ -23,8 +23,10 @@ import { getFormattedDuration, getFormattedMilliseconds } from '../../../lib/mon
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { useKibana } from '../../../../common/lib/kibana';
jest.mock('../../../../common/lib/kibana');
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { IToasts } from '@kbn/core/public';
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../lib/action_connector_api', () => ({
loadActionTypes: jest.fn(),
loadAllActions: jest.fn(),
@ -33,6 +35,7 @@ jest.mock('../../../lib/rule_api', () => ({
loadRules: jest.fn(),
loadRuleTypes: jest.fn(),
loadRuleAggregations: jest.fn(),
updateAPIKey: jest.fn(),
loadRuleTags: jest.fn(),
alertingFrameworkHealth: jest.fn(() => ({
isSufficientlySecure: true,
@ -67,12 +70,12 @@ jest.mock('../../../../common/get_experimental_features', () => ({
const ruleTags = ['a', 'b', 'c', 'd'];
const { loadRules, loadRuleTypes, loadRuleAggregations, loadRuleTags } =
const { loadRules, loadRuleTypes, loadRuleAggregations, updateAPIKey, loadRuleTags } =
jest.requireMock('../../../lib/rule_api');
const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api');
const actionTypeRegistry = actionTypeRegistryMock.create();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
const ruleType = {
id: 'test_rule_type',
description: 'test',
@ -101,11 +104,299 @@ const ruleTypeFromApi = {
};
ruleTypeRegistry.list.mockReturnValue([ruleType]);
actionTypeRegistry.list.mockReturnValue([]);
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const mockedRulesData = [
{
id: '1',
name: 'test rule',
tags: ['tag1'],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '1s' },
actions: [],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'active',
lastDuration: 500,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
monitoring: {
execution: {
history: [
{
success: true,
duration: 1000000,
},
{
success: true,
duration: 200000,
},
{
success: false,
duration: 300000,
},
],
calculated_metrics: {
success_ratio: 0.66,
p50: 200000,
p95: 300000,
p99: 300000,
},
},
},
},
{
id: '2',
name: 'test rule ok',
tags: ['tag1'],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'ok',
lastDuration: 61000,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
monitoring: {
execution: {
history: [
{
success: true,
duration: 100000,
},
{
success: true,
duration: 500000,
},
],
calculated_metrics: {
success_ratio: 1,
p50: 0,
p95: 100000,
p99: 500000,
},
},
},
},
{
id: '3',
name: 'test rule pending',
tags: ['tag1'],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'pending',
lastDuration: 30234,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
monitoring: {
execution: {
history: [{ success: false, duration: 100 }],
calculated_metrics: {
success_ratio: 0,
},
},
},
},
{
id: '4',
name: 'test rule error',
tags: ['tag1'],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'error',
lastDuration: 122000,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: {
reason: RuleExecutionStatusErrorReasons.Unknown,
message: 'test',
},
},
},
{
id: '5',
name: 'test rule license error',
tags: [],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'error',
lastDuration: 500,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: {
reason: RuleExecutionStatusErrorReasons.License,
message: 'test',
},
},
},
{
id: '6',
name: 'test rule warning',
tags: [],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'warning',
lastDuration: 500,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
warning: {
reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS,
message: 'test',
},
},
},
];
beforeEach(() => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
});
describe('Update Api Key', () => {
const addSuccess = jest.fn();
const addError = jest.fn();
beforeAll(() => {
loadRules.mockResolvedValue({
page: 1,
perPage: 10000,
total: 0,
data: mockedRulesData,
});
loadActionTypes.mockResolvedValue([]);
loadRuleTypes.mockResolvedValue([ruleTypeFromApi]);
loadAllActions.mockResolvedValue([]);
useKibanaMock().services.notifications.toasts = {
addSuccess,
addError,
} as unknown as IToasts;
});
afterEach(() => {
jest.clearAllMocks();
});
it('Updates the Api Key successfully', async () => {
updateAPIKey.mockResolvedValueOnce(204);
render(
<IntlProvider locale="en">
<RulesList />
</IntlProvider>
);
expect(await screen.findByText('test rule ok')).toBeInTheDocument();
fireEvent.click(screen.getAllByTestId('selectActionButton')[1]);
expect(screen.getByTestId('collapsedActionPanel')).toBeInTheDocument();
fireEvent.click(screen.getByText('Update API key'));
expect(screen.getByText('You will not be able to recover the old API key')).toBeInTheDocument();
fireEvent.click(screen.getByText('Cancel'));
expect(
screen.queryByText('You will not be able to recover the old API key')
).not.toBeInTheDocument();
fireEvent.click(screen.getAllByTestId('selectActionButton')[1]);
expect(screen.getByTestId('collapsedActionPanel')).toBeInTheDocument();
fireEvent.click(screen.getByText('Update API key'));
await act(async () => {
fireEvent.click(screen.getByText('Update'));
});
expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' }));
expect(loadRules).toHaveBeenCalledTimes(2);
expect(screen.queryByText("You can't recover the old API key")).not.toBeInTheDocument();
expect(addSuccess).toHaveBeenCalledWith('API key has been updated');
});
it('Update API key fails', async () => {
updateAPIKey.mockRejectedValueOnce(500);
render(
<IntlProvider locale="en">
<RulesList />
</IntlProvider>
);
expect(await screen.findByText('test rule ok')).toBeInTheDocument();
fireEvent.click(screen.getAllByTestId('selectActionButton')[1]);
expect(screen.getByTestId('collapsedActionPanel')).toBeInTheDocument();
fireEvent.click(screen.getByText('Update API key'));
expect(screen.getByText('You will not be able to recover the old API key')).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByText('Update'));
});
expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' }));
expect(loadRules).toHaveBeenCalledTimes(2);
expect(
screen.queryByText('You will not be able to recover the old API key')
).not.toBeInTheDocument();
expect(addError).toHaveBeenCalledWith(500, { title: 'Failed to update the API key' });
});
});
describe('rules_list component empty', () => {
let wrapper: ReactWrapper<any>;
@ -174,208 +465,6 @@ describe('rules_list component empty', () => {
describe('rules_list component with items', () => {
let wrapper: ReactWrapper<any>;
const mockedRulesData = [
{
id: '1',
name: 'test rule',
tags: ['tag1'],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '1s' },
actions: [],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'active',
lastDuration: 500,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
monitoring: {
execution: {
history: [
{
success: true,
duration: 1000000,
},
{
success: true,
duration: 200000,
},
{
success: false,
duration: 300000,
},
],
calculated_metrics: {
success_ratio: 0.66,
p50: 200000,
p95: 300000,
p99: 300000,
},
},
},
},
{
id: '2',
name: 'test rule ok',
tags: ['tag1'],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'ok',
lastDuration: 61000,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
monitoring: {
execution: {
history: [
{
success: true,
duration: 100000,
},
{
success: true,
duration: 500000,
},
],
calculated_metrics: {
success_ratio: 1,
p50: 0,
p95: 100000,
p99: 500000,
},
},
},
},
{
id: '3',
name: 'test rule pending',
tags: ['tag1'],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'pending',
lastDuration: 30234,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
monitoring: {
execution: {
history: [{ success: false, duration: 100 }],
calculated_metrics: {
success_ratio: 0,
},
},
},
},
{
id: '4',
name: 'test rule error',
tags: ['tag1'],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'error',
lastDuration: 122000,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: {
reason: RuleExecutionStatusErrorReasons.Unknown,
message: 'test',
},
},
},
{
id: '5',
name: 'test rule license error',
tags: [],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'error',
lastDuration: 500,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: {
reason: RuleExecutionStatusErrorReasons.License,
message: 'test',
},
},
},
{
id: '6',
name: 'test rule warning',
tags: [],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'warning',
lastDuration: 500,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
warning: {
reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS,
message: 'test',
},
},
},
];
async function setup(editable: boolean = true) {
loadRules.mockResolvedValue({
page: 1,

View file

@ -80,6 +80,7 @@ import {
snoozeRule,
unsnoozeRule,
deleteRules,
updateAPIKey,
} from '../../../lib/rule_api';
import { loadActionTypes } from '../../../lib/action_connector_api';
import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities';
@ -103,6 +104,7 @@ import { triggersActionsUiConfig } from '../../../../common/lib/config_api';
import { RuleTagFilter } from './rule_tag_filter';
import { RuleStatusFilter } from './rule_status_filter';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation';
const ENTER_KEY = 13;
@ -219,6 +221,7 @@ export const RulesList: React.FunctionComponent = () => {
totalItemCount: 0,
});
const [rulesToDelete, setRulesToDelete] = useState<string[]>([]);
const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState<string[]>([]);
const onRuleEdit = (ruleItem: RuleTableItem) => {
setEditFlyoutVisibility(true);
setCurrentRuleToEdit(ruleItem);
@ -903,6 +906,7 @@ export const RulesList: React.FunctionComponent = () => {
onRuleChanged={() => loadRulesData()}
setRulesToDelete={setRulesToDelete}
onEditRule={() => onRuleEdit(item)}
onUpdateAPIKey={setRulesToUpdateAPIKey}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -1330,7 +1334,7 @@ export const RulesList: React.FunctionComponent = () => {
await loadRulesData();
}}
onErrors={async () => {
// Refresh the rules from the server, some rules may have beend deleted
// Refresh the rules from the server, some rules may have been deleted
await loadRulesData();
setRulesToDelete([]);
}}
@ -1349,6 +1353,20 @@ export const RulesList: React.FunctionComponent = () => {
setRulesState({ ...rulesState, isLoading });
}}
/>
<UpdateApiKeyModalConfirmation
onCancel={() => {
setRulesToUpdateAPIKey([]);
}}
idsToUpdate={rulesToUpdateAPIKey}
apiUpdateApiKeyCall={updateAPIKey}
setIsLoadingState={(isLoading: boolean) => {
setRulesState({ ...rulesState, isLoading });
}}
onUpdated={async () => {
setRulesToUpdateAPIKey([]);
await loadRulesData();
}}
/>
<EuiSpacer size="xs" />
{getRulesList()}
{ruleFlyoutVisible && (

View file

@ -13,6 +13,7 @@ import {
KibanaResponseFactory,
IKibanaResponse,
Logger,
SavedObject,
} from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { InvalidatePendingApiKey } from '@kbn/alerting-plugin/server/types';
@ -364,4 +365,46 @@ export function defineRoutes(
}
}
);
router.get(
{
path: '/api/alerts_fixture/rule/{id}/_get_api_key',
validate: {
params: schema.object({
id: schema.string(),
}),
},
},
async function (
context: RequestHandlerContext,
req: KibanaRequest<any, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
const { id } = req.params;
const [, { encryptedSavedObjects, spaces }] = await core.getStartServices();
const spaceId = spaces ? spaces.spacesService.getSpaceId(req) : 'default';
let namespace: string | undefined;
if (spaces && spaceId) {
namespace = spaces.spacesService.spaceIdToNamespace(spaceId);
}
try {
const {
attributes: { apiKey, apiKeyOwner },
}: SavedObject<RawRule> = await encryptedSavedObjects
.getClient({
includedHiddenTypes: ['alert'],
})
.getDecryptedAsInternalUser('alert', id, {
namespace,
});
return res.ok({ body: { apiKey, apiKeyOwner } });
} catch (err) {
return res.badRequest({ body: err });
}
}
);
}

View file

@ -76,6 +76,16 @@ export class AlertUtils {
return request;
}
public getAPIKeyRequest(ruleId: string) {
const request = this.supertestWithoutAuth.get(
`${getUrlPrefix(this.space.id)}/api/alerts_fixture/rule/${ruleId}/_get_api_key`
);
if (this.user) {
return request.auth(this.user.username, this.user.password);
}
return request;
}
public getDisableRequest(alertId: string) {
const request = this.supertestWithoutAuth
.post(`${getUrlPrefix(this.space.id)}/api/alerting/rule/${alertId}/_disable`)

View file

@ -334,13 +334,6 @@ export default function createDisableAlertTests({ getService }: FtrProviderConte
} catch (e) {
expect(e.meta.statusCode).to.eql(404);
}
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: 'alert',
id: createdAlert.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);

View file

@ -31,6 +31,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./get_alert_summary'));
loadTestFile(require.resolve('./rule_types'));
loadTestFile(require.resolve('./bulk_edit'));
loadTestFile(require.resolve('./retain_api_key'));
});
});
}

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { UserAtSpaceScenarios } from '../../../scenarios';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { AlertUtils, getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib';
// eslint-disable-next-line import/no-default-export
export default function retainAPIKeyTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('retain api key', () => {
const objectRemover = new ObjectRemover(supertest);
after(() => objectRemover.removeAll());
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
const alertUtils = new AlertUtils({ user, space, supertestWithoutAuth });
describe(scenario.id, () => {
it('should retain the api key when a rule is disabled and then enabled', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(space.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'MY action',
connector_type_id: 'test.noop',
config: {},
secrets: {},
})
.expect(200);
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
actions: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
})
)
.expect(200);
objectRemover.add(space.id, createdRule.id, 'rule', 'alerting');
objectRemover.add(space.id, createdAction.id, 'action', 'actions');
const {
body: { apiKey, apiKeyOwner },
} = await alertUtils.getAPIKeyRequest(createdRule.id);
expect(apiKey).not.to.be(null);
expect(apiKey).not.to.be(undefined);
expect(apiKeyOwner).not.to.be(null);
expect(apiKeyOwner).not.to.be(undefined);
await alertUtils.getDisableRequest(createdRule.id);
const {
body: { apiKey: apiKeyAfterDisable, apiKeyOwner: apiKeyOwnerAfterDisable },
} = await alertUtils.getAPIKeyRequest(createdRule.id);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(apiKey).to.be(apiKeyAfterDisable);
expect(apiKeyOwner).to.be(apiKeyOwnerAfterDisable);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
await alertUtils.getEnableRequest(createdRule.id);
const {
body: { apiKey: apiKeyAfterEnable, apiKeyOwner: apiKeyOwnerAfterEnable },
} = await alertUtils.getAPIKeyRequest(createdRule.id);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(apiKey).to.be(apiKeyAfterEnable);
expect(apiKeyOwner).to.be(apiKeyOwnerAfterEnable);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}