Used SO for saving the API key IDs that should be deleted (#82211)

* Used SO for saving the API key IDs that should be deleted and create a configuration option where can set an execution interval for a TM task which will get the data from this SO and remove marked for delete keys.

* removed invalidateApiKey from AlertsClient

* Fixed type checks

* Fixed jest tests

* Removed test code

* Changed SO name

* fixed type cheks

* Moved invalidate logic out of alerts client

* fixed type check

* Added functional tests

* Fixed due to comments

* added configurable delay for invalidation task

* added interval to the task response

* Fixed jest tests

* Fixed due to comments

* Fixed task

* fixed paging

* Fixed date filter

* Fixed jest tests

* fixed due to comments

* fixed due to comments

* Fixed e2e test

* Fixed e2e test

* Fixed due to comments. Changed api key invalidation task to use SavedObjectClient

* Use encryptedSavedObjectClient

* set back flaky test comment
This commit is contained in:
Yuliia Naumenko 2020-11-17 06:44:54 -08:00 committed by GitHub
parent 2fb04a6d41
commit 8b658fbcd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 847 additions and 126 deletions

View file

@ -31,7 +31,6 @@ import {
} from '../types';
import { validateAlertTypeParams, alertExecutionStatusFromRaw } from '../lib';
import {
InvalidateAPIKeyParams,
GrantAPIKeyResult as SecurityPluginGrantAPIKeyResult,
InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult,
} from '../../../security/server';
@ -48,6 +47,7 @@ import { IEvent } from '../../../event_log/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';
export interface RegistryAlertTypeWithAuth extends RegistryAlertType {
authorizedConsumers: string[];
@ -72,7 +72,6 @@ export interface ConstructorOptions {
namespace?: string;
getUserName: () => Promise<string | null>;
createAPIKey: (name: string) => Promise<CreateAPIKeyResult>;
invalidateAPIKey: (params: InvalidateAPIKeyParams) => Promise<InvalidateAPIKeyResult>;
getActionsClient: () => Promise<ActionsClient>;
getEventLogClient: () => Promise<IEventLogClient>;
kibanaVersion: PluginInitializerContext['env']['packageInfo']['version'];
@ -172,9 +171,6 @@ export class AlertsClient {
private readonly authorization: AlertsAuthorization;
private readonly alertTypeRegistry: AlertTypeRegistry;
private readonly createAPIKey: (name: string) => Promise<CreateAPIKeyResult>;
private readonly invalidateAPIKey: (
params: InvalidateAPIKeyParams
) => Promise<InvalidateAPIKeyResult>;
private readonly getActionsClient: () => Promise<ActionsClient>;
private readonly actionsAuthorization: ActionsAuthorization;
private readonly getEventLogClient: () => Promise<IEventLogClient>;
@ -191,7 +187,6 @@ export class AlertsClient {
namespace,
getUserName,
createAPIKey,
invalidateAPIKey,
encryptedSavedObjectsClient,
getActionsClient,
actionsAuthorization,
@ -207,7 +202,6 @@ export class AlertsClient {
this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient;
this.authorization = authorization;
this.createAPIKey = createAPIKey;
this.invalidateAPIKey = invalidateAPIKey;
this.encryptedSavedObjectsClient = encryptedSavedObjectsClient;
this.getActionsClient = getActionsClient;
this.actionsAuthorization = actionsAuthorization;
@ -263,7 +257,11 @@ export class AlertsClient {
);
} catch (e) {
// Avoid unused API key
this.invalidateApiKey({ apiKey: rawAlert.apiKey });
markApiKeyForInvalidation(
{ apiKey: rawAlert.apiKey },
this.logger,
this.unsecuredSavedObjectsClient
);
throw e;
}
if (data.enabled) {
@ -487,7 +485,13 @@ export class AlertsClient {
await Promise.all([
taskIdToRemove ? deleteTaskIfItExists(this.taskManager, taskIdToRemove) : null,
apiKeyToInvalidate ? this.invalidateApiKey({ apiKey: apiKeyToInvalidate }) : null,
apiKeyToInvalidate
? markApiKeyForInvalidation(
{ apiKey: apiKeyToInvalidate },
this.logger,
this.unsecuredSavedObjectsClient
)
: null,
]);
return removeResult;
@ -526,7 +530,11 @@ export class AlertsClient {
await Promise.all([
alertSavedObject.attributes.apiKey
? this.invalidateApiKey({ apiKey: alertSavedObject.attributes.apiKey })
? markApiKeyForInvalidation(
{ apiKey: alertSavedObject.attributes.apiKey },
this.logger,
this.unsecuredSavedObjectsClient
)
: null,
(async () => {
if (
@ -591,7 +599,11 @@ export class AlertsClient {
);
} catch (e) {
// Avoid unused API key
this.invalidateApiKey({ apiKey: createAttributes.apiKey });
markApiKeyForInvalidation(
{ apiKey: createAttributes.apiKey },
this.logger,
this.unsecuredSavedObjectsClient
);
throw e;
}
@ -671,28 +683,20 @@ export class AlertsClient {
await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version });
} catch (e) {
// Avoid unused API key
this.invalidateApiKey({ apiKey: updateAttributes.apiKey });
markApiKeyForInvalidation(
{ apiKey: updateAttributes.apiKey },
this.logger,
this.unsecuredSavedObjectsClient
);
throw e;
}
if (apiKeyToInvalidate) {
await this.invalidateApiKey({ apiKey: apiKeyToInvalidate });
}
}
private async invalidateApiKey({ apiKey }: { apiKey: string | null }): Promise<void> {
if (!apiKey) {
return;
}
try {
const apiKeyId = Buffer.from(apiKey, 'base64').toString().split(':')[0];
const response = await this.invalidateAPIKey({ id: apiKeyId });
if (response.apiKeysEnabled === true && response.result.error_count > 0) {
this.logger.error(`Failed to invalidate API Key [id="${apiKeyId}"]`);
}
} catch (e) {
this.logger.error(`Failed to invalidate API Key: ${e.message}`);
await markApiKeyForInvalidation(
{ apiKey: apiKeyToInvalidate },
this.logger,
this.unsecuredSavedObjectsClient
);
}
}
@ -752,7 +756,11 @@ export class AlertsClient {
await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version });
} catch (e) {
// Avoid unused API key
this.invalidateApiKey({ apiKey: updateAttributes.apiKey });
markApiKeyForInvalidation(
{ apiKey: updateAttributes.apiKey },
this.logger,
this.unsecuredSavedObjectsClient
);
throw e;
}
const scheduledTask = await this.scheduleAlert(
@ -764,7 +772,11 @@ export class AlertsClient {
scheduledTaskId: scheduledTask.id,
});
if (apiKeyToInvalidate) {
await this.invalidateApiKey({ apiKey: apiKeyToInvalidate });
await markApiKeyForInvalidation(
{ apiKey: apiKeyToInvalidate },
this.logger,
this.unsecuredSavedObjectsClient
);
}
}
}
@ -825,7 +837,13 @@ export class AlertsClient {
attributes.scheduledTaskId
? deleteTaskIfItExists(this.taskManager, attributes.scheduledTaskId)
: null,
apiKeyToInvalidate ? this.invalidateApiKey({ apiKey: apiKeyToInvalidate }) : null,
apiKeyToInvalidate
? await markApiKeyForInvalidation(
{ apiKey: apiKeyToInvalidate },
this.logger,
this.unsecuredSavedObjectsClient
)
: null,
]);
}
}

View file

@ -34,7 +34,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),

View file

@ -34,7 +34,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
@ -711,7 +710,7 @@ describe('create()', () => {
expect(taskManager.schedule).not.toHaveBeenCalled();
});
test('throws error and invalidates API key when create saved object fails', async () => {
test('throws error and add API key to invalidatePendingApiKey SO when create saved object fails', async () => {
const data = getMockData();
alertsClientParams.createAPIKey.mockResolvedValueOnce({
apiKeysEnabled: true,
@ -731,11 +730,25 @@ describe('create()', () => {
],
});
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Test failure'));
const createdAt = new Date().toISOString();
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt,
},
references: [],
});
await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Test failure"`
);
expect(taskManager.schedule).not.toHaveBeenCalled();
expect(alertsClientParams.invalidateAPIKey).toHaveBeenCalledWith({ id: '123' });
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2);
expect(unsecuredSavedObjectsClient.create.mock.calls[1][1]).toStrictEqual({
apiKeyId: '123',
createdAt,
});
});
test('attempts to remove saved object if scheduling failed', async () => {

View file

@ -32,7 +32,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
@ -94,11 +93,22 @@ describe('delete()', () => {
});
test('successfully removes an alert', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const result = await alertsClient.delete({ id: '1' });
expect(result).toEqual({ success: true });
expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith('alert', '1');
expect(taskManager.remove).toHaveBeenCalledWith('task-123');
expect(alertsClientParams.invalidateAPIKey).toHaveBeenCalledWith({ id: '123' });
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe(
'api_key_pending_invalidation'
);
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
namespace: 'default',
});
@ -107,12 +117,21 @@ describe('delete()', () => {
test('falls back to SOC.get when getDecryptedAsInternalUser throws an error', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const result = await alertsClient.delete({ id: '1' });
expect(result).toEqual({ success: true });
expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith('alert', '1');
expect(taskManager.remove).toHaveBeenCalledWith('task-123');
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1');
expect(alertsClientParams.logger.error).toHaveBeenCalledWith(
'delete(): Failed to load API key to invalidate on alert 1: Fail'
@ -133,6 +152,15 @@ describe('delete()', () => {
});
test(`doesn't invalidate API key when apiKey is null`, async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({
...existingAlert,
attributes: {
@ -142,24 +170,34 @@ describe('delete()', () => {
});
await alertsClient.delete({ id: '1' });
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
});
test('swallows error when invalidate API key throws', async () => {
alertsClientParams.invalidateAPIKey.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail'));
await alertsClient.delete({ id: '1' });
expect(alertsClientParams.invalidateAPIKey).toHaveBeenCalledWith({ id: '123' });
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe(
'api_key_pending_invalidation'
);
expect(alertsClientParams.logger.error).toHaveBeenCalledWith(
'Failed to invalidate API Key: Fail'
'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail'
);
});
test('swallows error when getDecryptedAsInternalUser throws an error', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail'));
await alertsClient.delete({ id: '1' });
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
expect(alertsClientParams.logger.error).toHaveBeenCalledWith(
'delete(): Failed to load API key to invalidate on alert 1: Fail'
);

View file

@ -13,6 +13,7 @@ import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
import { getBeforeSetup } from './lib';
import { InvalidatePendingApiKey } from '../../types';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
@ -33,7 +34,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
@ -108,6 +108,15 @@ describe('disable()', () => {
});
test('disables an alert', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await alertsClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
@ -145,11 +154,22 @@ describe('disable()', () => {
}
);
expect(taskManager.remove).toHaveBeenCalledWith('task-123');
expect(alertsClientParams.invalidateAPIKey).toHaveBeenCalledWith({ id: '123' });
expect(
(unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId
).toBe('123');
});
test('falls back when getDecryptedAsInternalUser throws an error', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await alertsClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1');
@ -188,7 +208,7 @@ describe('disable()', () => {
}
);
expect(taskManager.remove).toHaveBeenCalledWith('task-123');
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
});
test(`doesn't disable already disabled alerts`, async () => {
@ -201,26 +221,54 @@ describe('disable()', () => {
},
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await alertsClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled();
expect(taskManager.remove).not.toHaveBeenCalled();
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
});
test(`doesn't invalidate when no API key is used`, async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce(existingAlert);
await alertsClient.disable({ id: '1' });
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
});
test('swallows error when failing to load decrypted saved object', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail'));
await alertsClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled();
expect(taskManager.remove).toHaveBeenCalled();
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
expect(alertsClientParams.logger.error).toHaveBeenCalledWith(
'disable(): Failed to load API key to invalidate on alert 1: Fail'
);
@ -235,11 +283,10 @@ describe('disable()', () => {
});
test('swallows error when invalidate API key throws', async () => {
alertsClientParams.invalidateAPIKey.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail'));
await alertsClient.disable({ id: '1' });
expect(alertsClientParams.logger.error).toHaveBeenCalledWith(
'Failed to invalidate API Key: Fail'
'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail'
);
});

View file

@ -14,6 +14,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
import { TaskStatus } from '../../../../task_manager/server';
import { getBeforeSetup } from './lib';
import { InvalidatePendingApiKey } from '../../types';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
@ -34,7 +35,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
@ -147,6 +147,7 @@ describe('enable()', () => {
});
test('enables an alert', async () => {
const createdAt = new Date().toISOString();
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
...existingAlert,
attributes: {
@ -157,13 +158,22 @@ describe('enable()', () => {
updatedBy: 'elastic',
},
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt,
},
references: [],
});
await alertsClient.enable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
namespace: 'default',
});
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation');
expect(alertsClientParams.createAPIKey).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith(
'alert',
@ -217,6 +227,7 @@ describe('enable()', () => {
});
test('invalidates API key if ever one existed prior to updating', async () => {
const createdAt = new Date().toISOString();
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({
...existingAlert,
attributes: {
@ -224,13 +235,24 @@ describe('enable()', () => {
apiKey: Buffer.from('123:abc').toString('base64'),
},
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt,
},
references: [],
});
await alertsClient.enable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
namespace: 'default',
});
expect(alertsClientParams.invalidateAPIKey).toHaveBeenCalledWith({ id: '123' });
expect(
(unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId
).toBe('123');
});
test(`doesn't enable already enabled alerts`, async () => {
@ -312,19 +334,31 @@ describe('enable()', () => {
});
test('throws error when failing to update the first time', async () => {
const createdAt = new Date().toISOString();
alertsClientParams.createAPIKey.mockResolvedValueOnce({
apiKeysEnabled: true,
result: { id: '123', name: '123', api_key: 'abc' },
});
unsecuredSavedObjectsClient.update.mockReset();
unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail to update'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt,
},
references: [],
});
await expect(alertsClient.enable({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Fail to update"`
);
expect(alertsClientParams.getUserName).toHaveBeenCalled();
expect(alertsClientParams.createAPIKey).toHaveBeenCalled();
expect(alertsClientParams.invalidateAPIKey).toHaveBeenCalledWith({ id: '123' });
expect(
(unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId
).toBe('123');
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1);
expect(taskManager.schedule).not.toHaveBeenCalled();
});

View file

@ -35,7 +35,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),

View file

@ -33,7 +33,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),

View file

@ -39,7 +39,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),

View file

@ -34,7 +34,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),

View file

@ -46,14 +46,6 @@ export function getBeforeSetup(
) {
jest.resetAllMocks();
alertsClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false });
alertsClientParams.invalidateAPIKey.mockResolvedValue({
apiKeysEnabled: true,
result: {
invalidated_api_keys: [],
previously_invalidated_api_keys: [],
error_count: 0,
},
});
alertsClientParams.getUserName.mockResolvedValue('elastic');
taskManager.runNow.mockResolvedValue({ id: '' });
const actionsClient = actionsClientMock.create();

View file

@ -33,7 +33,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),

View file

@ -32,7 +32,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),

View file

@ -33,7 +33,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),

View file

@ -33,7 +33,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),

View file

@ -33,7 +33,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),

View file

@ -10,7 +10,7 @@ import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src
import { taskManagerMock } from '../../../../task_manager/server/mocks';
import { alertTypeRegistryMock } from '../../alert_type_registry.mock';
import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock';
import { IntervalSchedule } from '../../types';
import { IntervalSchedule, InvalidatePendingApiKey } from '../../types';
import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks';
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
@ -38,7 +38,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
@ -161,6 +160,15 @@ describe('update()', () => {
},
],
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const result = await alertsClient.update({
id: '1',
data: {
@ -241,7 +249,7 @@ describe('update()', () => {
namespace: 'default',
});
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2);
expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3);
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert');
expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(`
@ -376,6 +384,24 @@ describe('update()', () => {
},
],
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const result = await alertsClient.update({
id: '1',
data: {
@ -423,7 +449,7 @@ describe('update()', () => {
"updatedAt": 2019-02-12T21:01:22.479Z,
}
`);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2);
expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3);
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert');
expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(`
@ -530,6 +556,15 @@ describe('update()', () => {
},
],
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const result = await alertsClient.update({
id: '1',
data: {
@ -578,7 +613,7 @@ describe('update()', () => {
"updatedAt": 2019-02-12T21:01:22.479Z,
}
`);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2);
expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3);
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert');
expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(`
@ -732,7 +767,6 @@ describe('update()', () => {
});
it('swallows error when invalidate API key throws', async () => {
alertsClientParams.invalidateAPIKey.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
{
@ -775,6 +809,7 @@ describe('update()', () => {
},
],
});
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); // add ApiKey to invalidate
await alertsClient.update({
id: '1',
data: {
@ -797,7 +832,7 @@ describe('update()', () => {
},
});
expect(alertsClientParams.logger.error).toHaveBeenCalledWith(
'Failed to invalidate API Key: Fail'
'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail'
);
});
@ -965,8 +1000,9 @@ describe('update()', () => {
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`);
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalledWith({ id: '123' });
expect(alertsClientParams.invalidateAPIKey).toHaveBeenCalledWith({ id: '234' });
expect(
(unsecuredSavedObjectsClient.create.mock.calls[1][1] as InvalidatePendingApiKey).apiKeyId
).toBe('234');
});
describe('updating an alert schedule', () => {

View file

@ -13,6 +13,7 @@ import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
import { getBeforeSetup } from './lib';
import { InvalidatePendingApiKey } from '../../types';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
@ -32,7 +33,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
@ -80,6 +80,15 @@ describe('updateApiKey()', () => {
beforeEach(() => {
alertsClient = new AlertsClient(alertsClientParams);
unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert);
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingEncryptedAlert);
alertsClientParams.createAPIKey.mockResolvedValueOnce({
apiKeysEnabled: true,
@ -121,11 +130,22 @@ describe('updateApiKey()', () => {
},
{ version: '123' }
);
expect(alertsClientParams.invalidateAPIKey).toHaveBeenCalledWith({ id: '123' });
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe(
'api_key_pending_invalidation'
);
});
test('falls back to SOC when getDecryptedAsInternalUser throws an error', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await alertsClient.updateApiKey({ id: '1' });
expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1');
@ -160,28 +180,37 @@ describe('updateApiKey()', () => {
},
{ version: '123' }
);
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
});
test('swallows error when invalidate API key throws', async () => {
alertsClientParams.invalidateAPIKey.mockRejectedValue(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail'));
await alertsClient.updateApiKey({ id: '1' });
expect(alertsClientParams.logger.error).toHaveBeenCalledWith(
'Failed to invalidate API Key: Fail'
);
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe(
'api_key_pending_invalidation'
);
});
test('swallows error when getting decrypted object throws', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await alertsClient.updateApiKey({ id: '1' });
expect(alertsClientParams.logger.error).toHaveBeenCalledWith(
'updateApiKey(): Failed to load API key to invalidate on alert 1: Fail'
);
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled();
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
});
test('throws when unsecuredSavedObjectsClient update fails and invalidates newly created API key', async () => {
@ -190,12 +219,22 @@ describe('updateApiKey()', () => {
result: { id: '234', name: '234', api_key: 'abc' },
});
unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await expect(alertsClient.updateApiKey({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Fail"`
);
expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalledWith({ id: '123' });
expect(alertsClientParams.invalidateAPIKey).toHaveBeenCalledWith({ id: '234' });
expect(
(unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId
).toBe('234');
});
describe('authorization', () => {

View file

@ -45,7 +45,6 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger,
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
@ -115,7 +114,7 @@ async function update(success: boolean) {
);
return expectConflict(success, err, 'create');
}
expectSuccess(success, 2, 'create');
expectSuccess(success, 3, 'create');
// only checking the debug messages in this test
expect(logger.debug).nthCalledWith(1, `alertsClient.update('alert-id') conflict, retrying ...`);
@ -306,14 +305,6 @@ beforeEach(() => {
jest.resetAllMocks();
alertsClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false });
alertsClientParams.invalidateAPIKey.mockResolvedValue({
apiKeysEnabled: true,
result: {
invalidated_api_keys: [],
previously_invalidated_api_keys: [],
error_count: 0,
},
});
alertsClientParams.getUserName.mockResolvedValue('elastic');
taskManager.runNow.mockResolvedValue({ id: '' });

View file

@ -92,7 +92,7 @@ test('creates an alerts client with proper constructor arguments when security i
expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, {
excludedWrappers: ['security'],
includedHiddenTypes: ['alert'],
includedHiddenTypes: ['alert', 'api_key_pending_invalidation'],
});
const { AlertsAuthorization } = jest.requireMock('./authorization/alerts_authorization');
@ -125,7 +125,6 @@ test('creates an alerts client with proper constructor arguments when security i
getActionsClient: expect.any(Function),
getEventLogClient: expect.any(Function),
createAPIKey: expect.any(Function),
invalidateAPIKey: expect.any(Function),
encryptedSavedObjectsClient: alertsClientFactoryParams.encryptedSavedObjectsClient,
kibanaVersion: '7.10.0',
});
@ -142,7 +141,7 @@ test('creates an alerts client with proper constructor arguments', async () => {
expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, {
excludedWrappers: ['security'],
includedHiddenTypes: ['alert'],
includedHiddenTypes: ['alert', 'api_key_pending_invalidation'],
});
const { AlertsAuthorization } = jest.requireMock('./authorization/alerts_authorization');
@ -167,7 +166,6 @@ test('creates an alerts client with proper constructor arguments', async () => {
namespace: 'default',
getUserName: expect.any(Function),
createAPIKey: expect.any(Function),
invalidateAPIKey: expect.any(Function),
encryptedSavedObjectsClient: alertsClientFactoryParams.encryptedSavedObjectsClient,
getActionsClient: expect.any(Function),
getEventLogClient: expect.any(Function),

View file

@ -14,7 +14,7 @@ import { PluginStartContract as ActionsPluginStartContract } from '../../actions
import { AlertsClient } from './alerts_client';
import { ALERTS_FEATURE_ID } from '../common';
import { AlertTypeRegistry, SpaceIdToNamespaceFunction } from './types';
import { InvalidateAPIKeyParams, SecurityPluginSetup } from '../../security/server';
import { SecurityPluginSetup } from '../../security/server';
import { EncryptedSavedObjectsClient } from '../../encrypted_saved_objects/server';
import { TaskManagerStartContract } from '../../task_manager/server';
import { PluginStartContract as FeaturesPluginStart } from '../../features/server';
@ -94,7 +94,7 @@ export class AlertsClientFactory {
alertTypeRegistry: this.alertTypeRegistry,
unsecuredSavedObjectsClient: savedObjects.getScopedClient(request, {
excludedWrappers: ['security'],
includedHiddenTypes: ['alert'],
includedHiddenTypes: ['alert', 'api_key_pending_invalidation'],
}),
authorization,
actionsAuthorization: actions.getActionsAuthorizationWithRequest(request),
@ -129,22 +129,6 @@ export class AlertsClientFactory {
result: createAPIKeyResult,
};
},
async invalidateAPIKey(params: InvalidateAPIKeyParams) {
if (!securityPluginSetup) {
return { apiKeysEnabled: false };
}
const invalidateAPIKeyResult = await securityPluginSetup.authc.invalidateAPIKeyAsInternalUser(
params
);
// Null when Elasticsearch security is disabled
if (!invalidateAPIKeyResult) {
return { apiKeysEnabled: false };
}
return {
apiKeysEnabled: true,
result: invalidateAPIKeyResult,
};
},
async getActionsClient() {
return actions.getActionsClientWithRequest(request);
},

View file

@ -13,6 +13,10 @@ describe('config validation', () => {
"healthCheck": Object {
"interval": "60m",
},
"invalidateApiKeysTask": Object {
"interval": "5m",
"removalDelay": "5m",
},
}
`);
});

View file

@ -11,6 +11,10 @@ export const configSchema = schema.object({
healthCheck: schema.object({
interval: schema.string({ validate: validateDurationSchema, defaultValue: '60m' }),
}),
invalidateApiKeysTask: schema.object({
interval: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }),
removalDelay: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }),
}),
});
export type AlertsConfig = TypeOf<typeof configSchema>;

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { loggingSystemMock, savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { markApiKeyForInvalidation } from './mark_api_key_for_invalidation';
describe('markApiKeyForInvalidation', () => {
test('should call savedObjectsClient create with the proper params', async () => {
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await markApiKeyForInvalidation(
{ apiKey: Buffer.from('123:abc').toString('base64') },
loggingSystemMock.create().get(),
unsecuredSavedObjectsClient
);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(2);
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual(
'api_key_pending_invalidation'
);
});
test('should log the proper error when savedObjectsClient create failed', async () => {
const logger = loggingSystemMock.create().get();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail'));
await markApiKeyForInvalidation(
{ apiKey: Buffer.from('123').toString('base64') },
logger,
unsecuredSavedObjectsClient
);
expect(logger.error).toHaveBeenCalledWith(
'Failed to mark for API key [id="MTIz"] for invalidation: Fail'
);
});
});

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Logger, SavedObjectsClientContract } from 'src/core/server';
export const markApiKeyForInvalidation = async (
{ apiKey }: { apiKey: string | null },
logger: Logger,
savedObjectsClient: SavedObjectsClientContract
): Promise<void> => {
if (!apiKey) {
return;
}
try {
const apiKeyId = Buffer.from(apiKey, 'base64').toString().split(':')[0];
await savedObjectsClient.create('api_key_pending_invalidation', {
apiKeyId,
createdAt: new Date().toISOString(),
});
} catch (e) {
logger.error(`Failed to mark for API key [id="${apiKey}"] for invalidation: ${e.message}`);
}
};

View file

@ -0,0 +1,226 @@
/*
* 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 {
Logger,
CoreStart,
SavedObjectsFindResponse,
KibanaRequest,
SavedObjectsClientContract,
} from 'kibana/server';
import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server';
import { InvalidateAPIKeyParams, SecurityPluginSetup } from '../../../security/server';
import {
RunContext,
TaskManagerSetupContract,
TaskManagerStartContract,
} from '../../../task_manager/server';
import { InvalidateAPIKeyResult } from '../alerts_client';
import { AlertsConfig } from '../config';
import { timePeriodBeforeDate } from '../lib/get_cadence';
import { AlertingPluginsStart } from '../plugin';
import { InvalidatePendingApiKey } from '../types';
const TASK_TYPE = 'alerts_invalidate_api_keys';
export const TASK_ID = `Alerts-${TASK_TYPE}`;
const invalidateAPIKey = async (
params: InvalidateAPIKeyParams,
securityPluginSetup?: SecurityPluginSetup
): Promise<InvalidateAPIKeyResult> => {
if (!securityPluginSetup) {
return { apiKeysEnabled: false };
}
const invalidateAPIKeyResult = await securityPluginSetup.authc.invalidateAPIKeyAsInternalUser(
params
);
// Null when Elasticsearch security is disabled
if (!invalidateAPIKeyResult) {
return { apiKeysEnabled: false };
}
return {
apiKeysEnabled: true,
result: invalidateAPIKeyResult,
};
};
export function initializeApiKeyInvalidator(
logger: Logger,
coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>,
taskManager: TaskManagerSetupContract,
config: Promise<AlertsConfig>,
securityPluginSetup?: SecurityPluginSetup
) {
registerApiKeyInvalitorTaskDefinition(
logger,
coreStartServices,
taskManager,
config,
securityPluginSetup
);
}
export async function scheduleApiKeyInvalidatorTask(
logger: Logger,
config: Promise<AlertsConfig>,
taskManager: TaskManagerStartContract
) {
const interval = (await config).invalidateApiKeysTask.interval;
try {
await taskManager.ensureScheduled({
id: TASK_ID,
taskType: TASK_TYPE,
schedule: {
interval,
},
state: {},
params: {},
});
} catch (e) {
logger.debug(`Error scheduling task, received ${e.message}`);
}
}
function registerApiKeyInvalitorTaskDefinition(
logger: Logger,
coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>,
taskManager: TaskManagerSetupContract,
config: Promise<AlertsConfig>,
securityPluginSetup?: SecurityPluginSetup
) {
taskManager.registerTaskDefinitions({
[TASK_TYPE]: {
title: 'Invalidate alert API Keys',
createTaskRunner: taskRunner(logger, coreStartServices, config, securityPluginSetup),
},
});
}
function getFakeKibanaRequest(basePath: string) {
const requestHeaders: Record<string, string> = {};
return ({
headers: requestHeaders,
getBasePath: () => basePath,
path: '/',
route: { settings: {} },
url: {
href: '/',
},
raw: {
req: {
url: '/',
},
},
} as unknown) as KibanaRequest;
}
function taskRunner(
logger: Logger,
coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>,
config: Promise<AlertsConfig>,
securityPluginSetup?: SecurityPluginSetup
) {
return ({ taskInstance }: RunContext) => {
const { state } = taskInstance;
return {
async run() {
let totalInvalidated = 0;
const configResult = await config;
try {
const [{ savedObjects, http }, { encryptedSavedObjects }] = await coreStartServices;
const savedObjectsClient = savedObjects.getScopedClient(
getFakeKibanaRequest(http.basePath.serverBasePath),
{
includedHiddenTypes: ['api_key_pending_invalidation'],
excludedWrappers: ['security'],
}
);
const encryptedSavedObjectsClient = encryptedSavedObjects.getClient({
includedHiddenTypes: ['api_key_pending_invalidation'],
});
const configuredDelay = configResult.invalidateApiKeysTask.removalDelay;
const delay = timePeriodBeforeDate(new Date(), configuredDelay).toISOString();
let hasApiKeysPendingInvalidation = true;
const PAGE_SIZE = 100;
do {
const apiKeysToInvalidate = await savedObjectsClient.find<InvalidatePendingApiKey>({
type: 'api_key_pending_invalidation',
filter: `api_key_pending_invalidation.attributes.createdAt <= "${delay}"`,
page: 1,
sortField: 'createdAt',
sortOrder: 'asc',
perPage: PAGE_SIZE,
});
totalInvalidated += await invalidateApiKeys(
logger,
savedObjectsClient,
apiKeysToInvalidate,
encryptedSavedObjectsClient,
securityPluginSetup
);
hasApiKeysPendingInvalidation = apiKeysToInvalidate.total > PAGE_SIZE;
} while (hasApiKeysPendingInvalidation);
return {
state: {
runs: (state.runs || 0) + 1,
total_invalidated: totalInvalidated,
},
schedule: {
interval: configResult.invalidateApiKeysTask.interval,
},
};
} catch (e) {
logger.warn(`Error executing alerting apiKey invalidation task: ${e.message}`);
return {
state: {
runs: (state.runs || 0) + 1,
total_invalidated: totalInvalidated,
},
schedule: {
interval: configResult.invalidateApiKeysTask.interval,
},
};
}
},
};
};
}
async function invalidateApiKeys(
logger: Logger,
savedObjectsClient: SavedObjectsClientContract,
apiKeysToInvalidate: SavedObjectsFindResponse<InvalidatePendingApiKey>,
encryptedSavedObjectsClient: EncryptedSavedObjectsClient,
securityPluginSetup?: SecurityPluginSetup
) {
let totalInvalidated = 0;
await Promise.all(
apiKeysToInvalidate.saved_objects.map(async (apiKeyObj) => {
const decryptedApiKey = await encryptedSavedObjectsClient.getDecryptedAsInternalUser<
InvalidatePendingApiKey
>('api_key_pending_invalidation', apiKeyObj.id);
const apiKeyId = decryptedApiKey.attributes.apiKeyId;
const response = await invalidateAPIKey({ id: apiKeyId }, securityPluginSetup);
if (response.apiKeysEnabled === true && response.result.error_count > 0) {
logger.error(`Failed to invalidate API Key [id="${apiKeyObj.attributes.apiKeyId}"]`);
} else {
try {
await savedObjectsClient.delete('api_key_pending_invalidation', apiKeyObj.id);
totalInvalidated++;
} catch (err) {
logger.error(
`Failed to cleanup api key "${apiKeyObj.attributes.apiKeyId}". Error: ${err.message}`
);
}
}
})
);
logger.debug(`Total invalidated api keys "${totalInvalidated}"`);
return totalInvalidated;
}

View file

@ -0,0 +1,53 @@
/*
* 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 { memoize } from 'lodash';
export enum TimeUnit {
Minute = 'm',
Second = 's',
Hour = 'h',
Day = 'd',
}
const VALID_CADENCE = new Set(Object.values(TimeUnit));
const CADENCE_IN_MS: Record<TimeUnit, number> = {
[TimeUnit.Second]: 1000,
[TimeUnit.Minute]: 60 * 1000,
[TimeUnit.Hour]: 60 * 60 * 1000,
[TimeUnit.Day]: 24 * 60 * 60 * 1000,
};
const isNumeric = (numAsStr: string) => /^\d+$/.test(numAsStr);
export const parseIntervalAsMillisecond = memoize((value: string): number => {
const numericAsStr: string = value.slice(0, -1);
const numeric: number = parseInt(numericAsStr, 10);
const cadence: TimeUnit | string = value.slice(-1);
if (
!VALID_CADENCE.has(cadence as TimeUnit) ||
isNaN(numeric) ||
numeric <= 0 ||
!isNumeric(numericAsStr)
) {
throw new Error(
`Invalid time value "${value}". Time must be of the form {number}m. Example: 5m.`
);
}
return numeric * CADENCE_IN_MS[cadence as TimeUnit];
});
/**
* Returns a date that is the specified interval from given date.
*
* @param {Date} date - The date to add interval to
* @param {string} interval - THe time of the form `Nm` such as `5m`
*/
export function timePeriodBeforeDate(date: Date, timePeriod: string): Date {
const result = new Date(date.valueOf());
const milisecFromTime = parseIntervalAsMillisecond(timePeriod);
result.setMilliseconds(result.getMilliseconds() - milisecFromTime);
return result;
}

View file

@ -22,6 +22,10 @@ describe('Alerting Plugin', () => {
healthCheck: {
interval: '5m',
},
invalidateApiKeysTask: {
interval: '5m',
removalDelay: '5m',
},
});
const plugin = new AlertingPlugin(context);
@ -67,6 +71,10 @@ describe('Alerting Plugin', () => {
healthCheck: {
interval: '5m',
},
invalidateApiKeysTask: {
interval: '5m',
removalDelay: '5m',
},
});
const plugin = new AlertingPlugin(context);
@ -114,6 +122,10 @@ describe('Alerting Plugin', () => {
healthCheck: {
interval: '5m',
},
invalidateApiKeysTask: {
interval: '5m',
removalDelay: '5m',
},
});
const plugin = new AlertingPlugin(context);

View file

@ -65,6 +65,10 @@ import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/
import { IEventLogger, IEventLogService, IEventLogClientService } from '../../event_log/server';
import { PluginStartContract as FeaturesPluginStart } from '../../features/server';
import { setupSavedObjects } from './saved_objects';
import {
initializeApiKeyInvalidator,
scheduleApiKeyInvalidatorTask,
} from './invalidate_pending_api_keys/task';
import {
getHealthStatusStream,
scheduleAlertingHealthCheck,
@ -200,6 +204,14 @@ export class AlertingPlugin {
});
}
initializeApiKeyInvalidator(
this.logger,
core.getStartServices(),
plugins.taskManager,
this.config,
this.security
);
core.getStartServices().then(async ([, startPlugins]) => {
core.status.set(
combineLatest([
@ -308,7 +320,9 @@ export class AlertingPlugin {
});
scheduleAlertingTelemetry(this.telemetryLogger, plugins.taskManager);
scheduleAlertingHealthCheck(this.logger, this.config, plugins.taskManager);
scheduleApiKeyInvalidatorTask(this.telemetryLogger, this.config, plugins.taskManager);
return {
listTypes: alertTypeRegistry!.list.bind(this.alertTypeRegistry!),

View file

@ -42,10 +42,32 @@ export function setupSavedObjects(
mappings: mappings.alert,
});
savedObjects.registerType({
name: 'api_key_pending_invalidation',
hidden: true,
namespaceType: 'agnostic',
mappings: {
properties: {
apiKeyId: {
type: 'keyword',
},
createdAt: {
type: 'date',
},
},
},
});
// Encrypted attributes
encryptedSavedObjects.registerType({
type: 'alert',
attributesToEncrypt: new Set(['apiKey']),
attributesToExcludeFromAAD: new Set(AlertAttributesExcludedFromAAD),
});
// Encrypted attributes
encryptedSavedObjects.registerType({
type: 'api_key_pending_invalidation',
attributesToEncrypt: new Set(['apiKeyId']),
});
}

View file

@ -180,4 +180,16 @@ export interface AlertsConfigType {
};
}
export interface AlertsConfigType {
invalidateApiKeysTask: {
interval: string;
removalDelay: string;
};
}
export interface InvalidatePendingApiKey {
apiKeyId: string;
createdAt: string;
}
export type AlertTypeRegistry = PublicMethodsOf<OrigAlertTypeRegistry>;

View file

@ -92,6 +92,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
...xPackApiIntegrationTestsConfig.get('kbnTestServer.serverArgs'),
`--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`,
'--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
'--xpack.alerts.invalidateApiKeysTask.interval="15s"',
`--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`,
...actionsProxyUrl,

View file

@ -437,6 +437,21 @@ export function defineAlertTypes(
throw new Error('this alert is intended to fail');
},
};
const longRunningAlertType: AlertType = {
id: 'test.longRunning',
name: 'Test: Long Running',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
producer: 'alertsFixture',
defaultActionGroupId: 'default',
async executor() {
await new Promise((resolve) => setTimeout(resolve, 5000));
},
};
alerts.registerType(getAlwaysFiringAlertType());
alerts.registerType(getCumulativeFiringAlertType());
@ -449,4 +464,5 @@ export function defineAlertTypes(
alerts.registerType(onlyStateVariablesAlertType);
alerts.registerType(getPatternFiringAlertType());
alerts.registerType(throwAlertType);
alerts.registerType(longRunningAlertType);
}

View file

@ -50,6 +50,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
'test.unrestricted-noop',
'test.patternFiring',
'test.throw',
'test.longRunning',
],
privileges: {
all: {
@ -72,6 +73,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
'test.unrestricted-noop',
'test.patternFiring',
'test.throw',
'test.longRunning',
],
},
ui: [],
@ -96,6 +98,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
'test.unrestricted-noop',
'test.patternFiring',
'test.throw',
'test.longRunning',
],
},
ui: [],

View file

@ -11,6 +11,7 @@ import {
IKibanaResponse,
} from 'kibana/server';
import { schema } from '@kbn/config-schema';
import { InvalidatePendingApiKey } from '../../../../../../../plugins/alerts/server/types';
import { RawAlert } from '../../../../../../../plugins/alerts/server/types';
import { TaskInstance } from '../../../../../../../plugins/task_manager/server';
import { FixtureSetupDeps, FixtureStartDeps } from './plugin';
@ -184,4 +185,31 @@ export function defineRoutes(
return res.ok({ body: result });
}
);
router.get(
{
path: '/api/alerts_fixture/api_keys_pending_invalidation',
validate: {},
},
async (
context: RequestHandlerContext,
req: KibanaRequest<any, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> => {
try {
const [{ savedObjects }] = await core.getStartServices();
const savedObjectsWithTasksAndAlerts = await savedObjects.getScopedClient(req, {
includedHiddenTypes: ['api_key_pending_invalidation'],
});
const findResult = await savedObjectsWithTasksAndAlerts.find<InvalidatePendingApiKey>({
type: 'api_key_pending_invalidation',
});
return res.ok({
body: { apiKeysToInvalidate: findResult.saved_objects },
});
} catch (err) {
return res.badRequest({ body: err });
}
}
);
}

View file

@ -836,6 +836,80 @@ export default function createUpdateTests({ getService }: FtrProviderContext) {
}
});
it('should handle updates for a long running alert type without failing the underlying tasks due to invalidated ApiKey', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerts/alert`)
.set('kbn-xsrf', 'foo')
.send({
enabled: true,
name: 'abc',
tags: ['foo'],
alertTypeId: 'test.longRunning',
consumer: 'alertsFixture',
schedule: { interval: '1s' },
throttle: '1m',
actions: [],
params: {},
})
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts');
const updatedData = {
name: 'bcd',
tags: ['bar'],
params: {
foo: true,
},
schedule: { interval: '1m' },
actions: [],
throttle: '1m',
};
const response = await supertestWithoutAuth
.put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(updatedData);
const statusUpdates: string[] = [];
await retry.try(async () => {
const alertTask = (await getAlertingTaskById(createdAlert.scheduledTaskId)).docs[0];
statusUpdates.push(alertTask.status);
expect(alertTask.status).to.eql('idle');
});
expect(statusUpdates.find((status) => status === 'failed')).to.be(undefined);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getConsumerUnauthorizedErrorMessage(
'update',
'test.longRunning',
'alertsFixture'
),
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(200);
await retry.try(async () => {
const alertTask = (await getAlertingTaskById(createdAlert.scheduledTaskId)).docs[0];
expect(alertTask.status).to.eql('idle');
// ensure the alert is rescheduled to a minute from now
ensureDatetimeIsWithinRange(Date.parse(alertTask.runAt), 60 * 1000);
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle updates to an alert schedule by setting the new schedule for the underlying task', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerts/alert`)