[Alerting] Export rules and connectors (#98802)

* Adding importableAndExportable but hidden saved object types to saved object feature privilege

* Adding helper function for transforming rule for export. Added audit logging

* Adding helper function for transforming rule for export. Added audit logging

* Adding unit test for transforming rules for export

* Exporting connectors

* Removing auditing during export

* Adding import/export to docs

* PR fixes

* Using action type validation onExport

* Fixing logic for connectors with optional secrets

* Fixing logic for connectors with optional secrets

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
ymao1 2021-05-05 11:33:01 -04:00 committed by GitHub
parent 0d7a5826a4
commit 4ab86c77d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 495 additions and 7 deletions

View file

@ -106,6 +106,12 @@ New connectors can be created by clicking the *Create connector* button, which w
[role="screenshot"]
image::images/connector-select-type.png[Connector select type]
[float]
[[importing-and-exporting-connectors]]
=== Importing and exporting connectors
To import and export rules, use the <<managing-saved-objects, Saved Objects Management UI>>.
[float]
[[create-connectors]]
=== Preconfigured connectors

View file

@ -57,6 +57,12 @@ These operations can also be performed in bulk by multi-selecting rules and clic
[role="screenshot"]
image:images/bulk-mute-disable.png[The Manage rules button lets you mute/unmute, enable/disable, and delete in bulk]
[float]
[[importing-and-exporting-rules]]
=== Importing and exporting rules
To import and export rules, use the <<managing-saved-objects, Saved Objects Management UI>>.
[float]
=== Required permissions

View file

@ -181,7 +181,6 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
}
plugins.features.registerKibanaFeature(ACTIONS_FEATURE);
setupSavedObjects(core.savedObjects, plugins.encryptedSavedObjects);
this.eventLogService = plugins.eventLog;
plugins.eventLog.registerProviderActions(EVENT_LOG_PROVIDER, Object.values(EVENT_LOG_ACTIONS));
@ -228,6 +227,8 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
this.actionExecutor = actionExecutor;
this.security = plugins.security;
setupSavedObjects(core.savedObjects, plugins.encryptedSavedObjects, this.actionTypeRegistry!);
registerBuiltInActionTypes({
logger: this.logger,
actionTypeRegistry,

View file

@ -5,12 +5,18 @@
* 2.0.
*/
import { SavedObject, SavedObjectsServiceSetup } from 'kibana/server';
import {
SavedObject,
SavedObjectsExportTransformContext,
SavedObjectsServiceSetup,
} from 'kibana/server';
import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server';
import mappings from './mappings.json';
import { getMigrations } from './migrations';
import { RawAction } from '../types';
import { getImportResultMessage, GO_TO_CONNECTORS_BUTTON_LABLE } from './get_import_result_message';
import { transformConnectorsForExport } from './transform_connectors_for_export';
import { ActionTypeRegistry } from '../action_type_registry';
export const ACTION_SAVED_OBJECT_TYPE = 'action';
export const ALERT_SAVED_OBJECT_TYPE = 'alert';
@ -18,7 +24,8 @@ export const ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE = 'action_task_params';
export function setupSavedObjects(
savedObjects: SavedObjectsServiceSetup,
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup,
actionTypeRegistry: ActionTypeRegistry
) {
savedObjects.registerType({
name: ACTION_SAVED_OBJECT_TYPE,
@ -32,6 +39,12 @@ export function setupSavedObjects(
getTitle(obj) {
return `Connector: [${obj.attributes.name}]`;
},
onExport<RawAction>(
context: SavedObjectsExportTransformContext,
objects: Array<SavedObject<RawAction>>
) {
return transformConnectorsForExport(objects, actionTypeRegistry);
},
onImport(connectors) {
return {
warnings: [

View file

@ -0,0 +1,253 @@
/*
* 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 { transformConnectorsForExport } from './transform_connectors_for_export';
import { ActionTypeRegistry, ActionTypeRegistryOpts } from '../action_type_registry';
import { loggingSystemMock } from '../../../../../src/core/server/mocks';
import { actionsConfigMock } from '../actions_config.mock';
import { licensingMock } from '../../../licensing/server/mocks';
import { licenseStateMock } from '../lib/license_state.mock';
import { taskManagerMock } from '../../../task_manager/server/mocks';
import { ActionExecutor, TaskRunnerFactory } from '../lib';
import { registerBuiltInActionTypes } from '../builtin_action_types';
describe('transform connector for export', () => {
const actionTypeRegistryParams: ActionTypeRegistryOpts = {
licensing: licensingMock.createSetup(),
taskManager: taskManagerMock.createSetup(),
taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })),
actionsConfigUtils: actionsConfigMock.create(),
licenseState: licenseStateMock.create(),
preconfiguredActions: [],
};
const actionTypeRegistry: ActionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
registerBuiltInActionTypes({
logger: loggingSystemMock.create().get(),
actionTypeRegistry,
actionsConfigUtils: actionsConfigMock.create(),
});
const connectorsWithNoSecrets = [
{
id: '1',
type: 'action',
attributes: {
actionTypeId: '.email',
name: 'email connector without auth',
isMissingSecrets: false,
config: {
hasAuth: false,
from: 'me@me.com',
host: 'host',
port: 22,
service: null,
secure: null,
},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '2',
type: 'action',
attributes: {
actionTypeId: '.index',
name: 'index connector',
isMissingSecrets: false,
config: {
index: 'test-index',
refresh: false,
executionTimeField: null,
},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '3',
type: 'action',
attributes: {
actionTypeId: '.server-log',
name: 'server log connector',
isMissingSecrets: false,
config: {},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '4',
type: 'action',
attributes: {
actionTypeId: '.webhook',
name: 'webhook connector without auth',
isMissingSecrets: false,
config: {
method: 'post',
hasAuth: false,
url: 'https://webhook',
headers: {},
},
secrets: 'asbqw4tqbef',
},
references: [],
},
];
const connectorsWithSecrets = [
{
id: '1',
type: 'action',
attributes: {
actionTypeId: '.email',
name: 'email connector with auth',
isMissingSecrets: false,
config: {
hasAuth: true,
from: 'me@me.com',
host: 'host',
port: 22,
service: null,
secure: null,
},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '2',
type: 'action',
attributes: {
actionTypeId: '.resilient',
name: 'resilient connector',
isMissingSecrets: false,
config: {
apiUrl: 'https://resilient',
orgId: 'origId',
},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '3',
type: 'action',
attributes: {
actionTypeId: '.servicenow',
name: 'servicenow itsm connector',
isMissingSecrets: false,
config: {
apiUrl: 'https://servicenow',
},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '4',
type: 'action',
attributes: {
actionTypeId: '.pagerduty',
name: 'pagerduty connector',
isMissingSecrets: false,
config: {
apiUrl: 'https://pagerduty',
},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '5',
type: 'action',
attributes: {
actionTypeId: '.jira',
name: 'jira connector',
isMissingSecrets: false,
config: {
apiUrl: 'https://jira',
projectKey: 'foo',
},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '6',
type: 'action',
attributes: {
actionTypeId: '.teams',
name: 'teams connector',
isMissingSecrets: false,
config: {},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '7',
type: 'action',
attributes: {
actionTypeId: '.slack',
name: 'slack connector',
isMissingSecrets: false,
config: {},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '8',
type: 'action',
attributes: {
actionTypeId: '.servicenow-sir',
name: 'servicenow sir connector',
isMissingSecrets: false,
config: {
apiUrl: 'https://servicenow-sir',
},
secrets: 'asbqw4tqbef',
},
references: [],
},
{
id: '8',
type: 'action',
attributes: {
actionTypeId: '.webhook',
name: 'webhook connector with auth',
isMissingSecrets: false,
config: {
method: 'post',
hasAuth: true,
url: 'https://webhook',
headers: {},
},
secrets: 'asbqw4tqbef',
},
references: [],
},
];
it('should not change connectors without secrets', () => {
expect(transformConnectorsForExport(connectorsWithNoSecrets, actionTypeRegistry)).toEqual(
connectorsWithNoSecrets
);
});
it('should remove secrets for connectors with secrets', () => {
expect(transformConnectorsForExport(connectorsWithSecrets, actionTypeRegistry)).toEqual(
connectorsWithSecrets.map((connector) => ({
...connector,
attributes: {
...connector.attributes,
isMissingSecrets: true,
},
}))
);
});
});

View file

@ -0,0 +1,50 @@
/*
* 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 { SavedObject } from 'kibana/server';
import { ActionTypeRegistry } from '../action_type_registry';
import { validateSecrets } from '../lib';
import { RawAction, ActionType } from '../types';
export function transformConnectorsForExport(
connectors: SavedObject[],
actionTypeRegistry: ActionTypeRegistry
): Array<SavedObject<RawAction>> {
return connectors.map((c) => {
const connector = c as SavedObject<RawAction>;
return transformConnectorForExport(
connector,
actionTypeRegistry.get(connector.attributes.actionTypeId)
);
});
}
function transformConnectorForExport(
connector: SavedObject<RawAction>,
actionType: ActionType
): SavedObject<RawAction> {
let isMissingSecrets = false;
try {
// If connector requires secrets, this will throw an error
validateSecrets(actionType, {});
// If connector has optional (or no) secrets, set isMissingSecrets value to value of hasAuth
// If connector doesn't have hasAuth value, default to isMissingSecrets: false
isMissingSecrets = (connector?.attributes?.config?.hasAuth as boolean) ?? false;
} catch (err) {
isMissingSecrets = true;
}
// Skip connectors
return {
...connector,
attributes: {
...connector.attributes,
isMissingSecrets,
},
};
}

View file

@ -5,11 +5,15 @@
* 2.0.
*/
import { SavedObjectsServiceSetup } from 'kibana/server';
import {
SavedObject,
SavedObjectsExportTransformContext,
SavedObjectsServiceSetup,
} from 'kibana/server';
import mappings from './mappings.json';
import { getMigrations } from './migrations';
import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server';
import { transformRulesForExport } from './transform_rule_for_export';
export { partiallyUpdateAlert } from './partially_update_alert';
export const AlertAttributesExcludedFromAAD = [
@ -43,6 +47,18 @@ export function setupSavedObjects(
namespaceType: 'single',
migrations: getMigrations(encryptedSavedObjects),
mappings: mappings.alert,
management: {
importableAndExportable: true,
getTitle(obj) {
return `Rule: [${obj.attributes.name}]`;
},
onExport<RawAlert>(
context: SavedObjectsExportTransformContext,
objects: Array<SavedObject<RawAlert>>
) {
return transformRulesForExport(objects);
},
},
});
savedObjects.registerType({

View file

@ -0,0 +1,91 @@
/*
* 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 { transformRulesForExport } from './transform_rule_for_export';
describe('transform rule for export', () => {
const date = new Date().toISOString();
const mockRules = [
{
id: '1',
type: 'alert',
attributes: {
enabled: true,
name: 'rule-name',
tags: ['tag-1', 'tag-2'],
alertTypeId: '123',
consumer: 'alert-consumer',
schedule: { interval: '1m' },
actions: [],
params: {},
createdBy: 'me',
updatedBy: 'me',
createdAt: date,
updatedAt: date,
apiKey: '4tndskbuhewotw4klrhgjewrt9u',
apiKeyOwner: 'me',
throttle: null,
notifyWhen: 'onActionGroupChange',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'active',
lastExecutionDate: '2020-08-20T19:23:38Z',
error: null,
},
scheduledTaskId: '2q5tjbf3q45twer',
},
references: [],
},
{
id: '2',
type: 'alert',
attributes: {
enabled: false,
name: 'disabled-rule',
tags: ['tag-1'],
alertTypeId: '456',
consumer: 'alert-consumer',
schedule: { interval: '1h' },
actions: [],
params: {},
createdBy: 'you',
updatedBy: 'you',
createdAt: date,
updatedAt: date,
apiKey: null,
apiKeyOwner: null,
throttle: null,
notifyWhen: 'onActionGroupChange',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'pending',
lastExecutionDate: '2020-08-20T19:23:38Z',
error: null,
},
scheduledTaskId: null,
},
references: [],
},
];
it('should disable rule and clear sensitive values', () => {
expect(transformRulesForExport(mockRules)).toEqual(
mockRules.map((rule) => ({
...rule,
attributes: {
...rule.attributes,
enabled: false,
apiKey: null,
apiKeyOwner: null,
scheduledTaskId: null,
},
}))
);
});
});

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 { SavedObject } from 'kibana/server';
import { RawAlert } from '../types';
export function transformRulesForExport(rules: SavedObject[]): Array<SavedObject<RawAlert>> {
return rules.map((rule) => transformRuleForExport(rule as SavedObject<RawAlert>));
}
function transformRuleForExport(rule: SavedObject<RawAlert>): SavedObject<RawAlert> {
return {
...rule,
attributes: {
...rule.attributes,
enabled: false,
apiKey: null,
apiKeyOwner: null,
scheduledTaskId: null,
},
};
}

View file

@ -27,6 +27,20 @@ describe('Features Plugin', () => {
namespaceType: 'single' as 'single',
},
]);
typeRegistry.getImportableAndExportableTypes.mockReturnValue([
{
name: 'hidden-importableAndExportable',
hidden: true,
mappings: { properties: {} },
namespaceType: 'single' as 'single',
},
{
name: 'not-hidden-importableAndExportable',
hidden: false,
mappings: { properties: {} },
namespaceType: 'single' as 'single',
},
]);
coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry);
});
@ -87,7 +101,9 @@ describe('Features Plugin', () => {
`);
});
it('registers kibana features with not hidden saved objects types', async () => {
it('registers kibana features with visible saved objects types and hidden saved object types that are importable and exportable', async () => {
typeRegistry.isHidden.mockReturnValueOnce(true);
typeRegistry.isHidden.mockReturnValueOnce(false);
const plugin = new FeaturesPlugin(initContext);
await plugin.setup(coreSetup, {});
const { getKibanaFeatures } = plugin.start(coreStart);
@ -98,6 +114,8 @@ describe('Features Plugin', () => {
expect(soTypes.includes('foo')).toBe(true);
expect(soTypes.includes('bar')).toBe(false);
expect(soTypes.includes('hidden-importableAndExportable')).toBe(true);
expect(soTypes.includes('not-hidden-importableAndExportable')).toBe(false);
});
it('returns registered elasticsearch features', async () => {

View file

@ -128,7 +128,15 @@ export class FeaturesPlugin
private registerOssFeatures(savedObjects: SavedObjectsServiceStart) {
const registry = savedObjects.getTypeRegistry();
const savedObjectTypes = registry.getVisibleTypes().map((t) => t.name);
const savedObjectVisibleTypes = registry.getVisibleTypes().map((t) => t.name);
const savedObjectImportableAndExportableHiddenTypes = registry
.getImportableAndExportableTypes()
.filter((t) => registry.isHidden(t.name))
.map((t) => t.name);
const savedObjectTypes = Array.from(
new Set([...savedObjectVisibleTypes, ...savedObjectImportableAndExportableHiddenTypes])
);
this.logger.debug(
`Registering OSS features with SO types: ${savedObjectTypes.join(', ')}. "includeTimelion": ${