diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 4d6dcb631792e..6cdb1dbfa712e 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -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 <>. + [float] [[create-connectors]] === Preconfigured connectors diff --git a/docs/user/alerting/rule-management.asciidoc b/docs/user/alerting/rule-management.asciidoc index b908bd03b0992..b15c46254b770 100644 --- a/docs/user/alerting/rule-management.asciidoc +++ b/docs/user/alerting/rule-management.asciidoc @@ -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 <>. + [float] === Required permissions diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 2036ed6c7d343..e3396d542cb62 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -181,7 +181,6 @@ export class ActionsPlugin implements Plugin( + context: SavedObjectsExportTransformContext, + objects: Array> + ) { + return transformConnectorsForExport(objects, actionTypeRegistry); + }, onImport(connectors) { return { warnings: [ diff --git a/x-pack/plugins/actions/server/saved_objects/transform_connectors_for_export.test.ts b/x-pack/plugins/actions/server/saved_objects/transform_connectors_for_export.test.ts new file mode 100644 index 0000000000000..63fe7c0e32047 --- /dev/null +++ b/x-pack/plugins/actions/server/saved_objects/transform_connectors_for_export.test.ts @@ -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, + }, + })) + ); + }); +}); diff --git a/x-pack/plugins/actions/server/saved_objects/transform_connectors_for_export.ts b/x-pack/plugins/actions/server/saved_objects/transform_connectors_for_export.ts new file mode 100644 index 0000000000000..050fa20b7fdab --- /dev/null +++ b/x-pack/plugins/actions/server/saved_objects/transform_connectors_for_export.ts @@ -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> { + return connectors.map((c) => { + const connector = c as SavedObject; + return transformConnectorForExport( + connector, + actionTypeRegistry.get(connector.attributes.actionTypeId) + ); + }); +} + +function transformConnectorForExport( + connector: SavedObject, + actionType: ActionType +): SavedObject { + 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, + }, + }; +} diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index bb4383083fedc..f4a1c0386b54c 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -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( + context: SavedObjectsExportTransformContext, + objects: Array> + ) { + return transformRulesForExport(objects); + }, + }, }); savedObjects.registerType({ diff --git a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts new file mode 100644 index 0000000000000..bf181e7299220 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts @@ -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, + }, + })) + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts new file mode 100644 index 0000000000000..c33bbceaf8363 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts @@ -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> { + return rules.map((rule) => transformRuleForExport(rule as SavedObject)); +} + +function transformRuleForExport(rule: SavedObject): SavedObject { + return { + ...rule, + attributes: { + ...rule.attributes, + enabled: false, + apiKey: null, + apiKeyOwner: null, + scheduledTaskId: null, + }, + }; +} diff --git a/x-pack/plugins/features/server/plugin.test.ts b/x-pack/plugins/features/server/plugin.test.ts index 0de03e54e1f79..ba809187a549e 100644 --- a/x-pack/plugins/features/server/plugin.test.ts +++ b/x-pack/plugins/features/server/plugin.test.ts @@ -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 () => { diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts index 09a5b78ad868a..60a48a539f81e 100644 --- a/x-pack/plugins/features/server/plugin.ts +++ b/x-pack/plugins/features/server/plugin.ts @@ -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": ${