diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9cdf6e013bd253..160d7267a4f221 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -89,6 +89,7 @@ /x-pack/test/search_sessions_integration/ @elastic/kibana-app-services /test/plugin_functional/test_suites/panel_actions @elastic/kibana-app-services /test/plugin_functional/test_suites/data_plugin @elastic/kibana-app-services +/x-pack/plugins/notifications/ @elastic/kibana-app-services ### Observability Plugins diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 32d807e016ebbb..1a9a610733f54d 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -589,6 +589,10 @@ Elastic. |This plugin allows for other plugins to add data to Kibana stack monitoring documents. +|{kib-repo}blob/{branch}/x-pack/plugins/notifications/README.md[notifications] +|The Notifications plugin provides a set of services to help Solutions and plugins send notifications to users. + + |{kib-repo}blob/{branch}/x-pack/plugins/observability/README.md[observability] |This plugin provides shared components and services for use across observability solutions, as well as the observability landing page UI. diff --git a/src/plugins/telemetry/README.md b/src/plugins/telemetry/README.md index df0070effe7546..6b57eeda9dc80d 100644 --- a/src/plugins/telemetry/README.md +++ b/src/plugins/telemetry/README.md @@ -51,14 +51,14 @@ To use the exposed plugin start and setup contracts: import { TelemetryPluginsStart } from '../telemetry/server`; -interface MyPlyginStartDeps { +interface MyPluginStartDeps { telemetry?: TelemetryPluginsStart; } class MyPlugin { public async start( core: CoreStart, - { telemetry }: MyPlyginStartDeps + { telemetry }: MyPluginStartDeps ) { const isOptedIn = await telemetry?.getIsOptedIn(); ... diff --git a/tsconfig.base.json b/tsconfig.base.json index 95f620b6e43820..b5372e27d631ca 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1104,6 +1104,8 @@ "@kbn/monitoring-collection-plugin/*": ["x-pack/plugins/monitoring_collection/*"], "@kbn/monitoring-plugin": ["x-pack/plugins/monitoring"], "@kbn/monitoring-plugin/*": ["x-pack/plugins/monitoring/*"], + "@kbn/notifications-plugin": ["x-pack/plugins/notifications"], + "@kbn/notifications-plugin/*": ["x-pack/plugins/notifications/*"], "@kbn/observability-plugin": ["x-pack/plugins/observability"], "@kbn/observability-plugin/*": ["x-pack/plugins/observability/*"], "@kbn/osquery-plugin": ["x-pack/plugins/osquery"], diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 4d5846de9528fa..34e02b9c43e588 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -39,7 +39,7 @@ const createStartMock = () => { isActionTypeEnabled: jest.fn(), isActionExecutable: jest.fn(), getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClientMock.create()), - getUnsecuredActionsClient: jest.fn().mockResolvedValue(unsecuredActionsClientMock.create()), + getUnsecuredActionsClient: jest.fn().mockReturnValue(unsecuredActionsClientMock.create()), getActionsAuthorizationWithRequest: jest .fn() .mockReturnValue(actionsAuthorizationMock.create()), diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index e24ac8247bfd87..8d2f7d6bb6320a 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -102,7 +102,7 @@ import { IServiceAbstract, SubActionConnectorType } from './sub_action_framework import { SubActionConnector } from './sub_action_framework/sub_action_connector'; import { CaseConnector } from './sub_action_framework/case'; import { - IUnsecuredActionsClient, + type IUnsecuredActionsClient, UnsecuredActionsClient, } from './unsecured_actions_client/unsecured_actions_client'; import { createBulkUnsecuredExecutionEnqueuerFunction } from './create_unsecured_execute_function'; diff --git a/x-pack/plugins/actions/server/unsecured_actions_client/unsecured_actions_client.mock.ts b/x-pack/plugins/actions/server/unsecured_actions_client/unsecured_actions_client.mock.ts index eb8d4de53e7f3b..61318a4707ca8e 100644 --- a/x-pack/plugins/actions/server/unsecured_actions_client/unsecured_actions_client.mock.ts +++ b/x-pack/plugins/actions/server/unsecured_actions_client/unsecured_actions_client.mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IUnsecuredActionsClient } from './unsecured_actions_client'; +import type { IUnsecuredActionsClient } from './unsecured_actions_client'; export type UnsecuredActionsClientMock = jest.Mocked; diff --git a/x-pack/plugins/notifications/README.md b/x-pack/plugins/notifications/README.md new file mode 100755 index 00000000000000..75ea13570ec2b3 --- /dev/null +++ b/x-pack/plugins/notifications/README.md @@ -0,0 +1,70 @@ +# Kibana Notifications Plugin + +The Notifications plugin provides a set of services to help Solutions and plugins send notifications to users. + +## Notifications Plugin public API + +### Start + +The `start` function exposes the following interface: + +- `isEmailServiceAvailable(): boolean`: + A function to check whether the deployment is properly configured and the EmailService can be correctly retrieved. +- `getEmailService(): EmailService`: +- A function to get the basic EmailService, which can be used to send plain text emails. If the EmailService is not available, trying to retrieve it will result in an Exception. + + +### Usage + +To use the exposed plugin start contract: + +1. Make sure `notifications` is in your `optionalPlugins` in the `kibana.json` file: + +```json5 +// /kibana.json +{ +"id": "...", +"requiredPlugins": ["notifications"] +} +``` + +2. Use the exposed contract: + +```ts +// /server/plugin.ts +import { NotificationsPluginStart } from '../notifications/server`; + +interface MyPluginStartDeps { + notifications?: NotificationsPluginStart; +} + +class MyPlugin { + public start( + core: CoreStart, + { notifications }: MyPluginStartDeps + ) { + if (notifications.isEmailServiceAvailable()) { + const emailService = notifications.getEmailService(); + emailService.sendPlainTextEmail({ + to: 'foo@bar.com', + subject: 'Some subject', + message: 'Hello world!', + }); + } + ... + } +} +``` + +### Requirements + +- This plugin currently depends on the `'actions'` plugin, as it uses `Connectors` under the hood. +- Note also that for each notification channel the corresponding connector must be preconfigured. E.g. to enable email notifications, an `Email` connector must exist in the system. +- Once the appropriate connectors are preconfigured in `kibana.yaml`, you can configure the `'notifications'` plugin by adding: + + ```yaml + notifications: + connectors: + default: + email: elastic-cloud-email # The identifier of the configured connector + ``` diff --git a/x-pack/plugins/notifications/common/index.ts b/x-pack/plugins/notifications/common/index.ts new file mode 100644 index 00000000000000..bc315c3c9e028f --- /dev/null +++ b/x-pack/plugins/notifications/common/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export const PLUGIN_ID = 'notifications'; diff --git a/x-pack/plugins/notifications/jest.config.js b/x-pack/plugins/notifications/jest.config.js new file mode 100644 index 00000000000000..b19a8f2efe3349 --- /dev/null +++ b/x-pack/plugins/notifications/jest.config.js @@ -0,0 +1,15 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../..', + roots: ['/x-pack/plugins/notifications'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/notifications', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/x-pack/plugins/notifications/{common,server}/**/*.{js,ts,tsx}'], +}; diff --git a/x-pack/plugins/notifications/kibana.json b/x-pack/plugins/notifications/kibana.json new file mode 100755 index 00000000000000..45cf4c4cd47b0c --- /dev/null +++ b/x-pack/plugins/notifications/kibana.json @@ -0,0 +1,12 @@ +{ + "id": "notifications", + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, + "version": "kibana", + "server": true, + "ui": false, + "requiredPlugins": ["actions", "licensing"], + "optionalPlugins": [] +} diff --git a/x-pack/plugins/notifications/server/config/config.ts b/x-pack/plugins/notifications/server/config/config.ts new file mode 100644 index 00000000000000..f2dc570adabe98 --- /dev/null +++ b/x-pack/plugins/notifications/server/config/config.ts @@ -0,0 +1,29 @@ +/* + * 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 { schema, type TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from '@kbn/core/server'; + +export const configSchema = schema.object( + { + connectors: schema.maybe( + schema.object({ + default: schema.maybe( + schema.object({ + email: schema.maybe(schema.string()), + }) + ), + }) + ), + }, + { defaultValue: {} } +); +export type NotificationsConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; diff --git a/x-pack/plugins/notifications/server/config/index.ts b/x-pack/plugins/notifications/server/config/index.ts new file mode 100644 index 00000000000000..662050c13d2ec7 --- /dev/null +++ b/x-pack/plugins/notifications/server/config/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { type NotificationsConfigType, config } from './config'; diff --git a/x-pack/plugins/notifications/server/index.ts b/x-pack/plugins/notifications/server/index.ts new file mode 100755 index 00000000000000..9e8785d680de52 --- /dev/null +++ b/x-pack/plugins/notifications/server/index.ts @@ -0,0 +1,18 @@ +/* + * 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 type { PluginInitializerContext } from '@kbn/core/server'; +import { NotificationsPlugin } from './plugin'; +export { config } from './config'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. +export type { NotificationsPluginStart } from './types'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new NotificationsPlugin(initializerContext); +} diff --git a/x-pack/plugins/notifications/server/mocks.ts b/x-pack/plugins/notifications/server/mocks.ts new file mode 100644 index 00000000000000..6360e0ece597b8 --- /dev/null +++ b/x-pack/plugins/notifications/server/mocks.ts @@ -0,0 +1,52 @@ +/* + * 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 type { PublicMethodsOf } from '@kbn/utility-types'; +import type { EmailService } from './services'; +import type { NotificationsPluginStart } from './types'; +import type { NotificationsPlugin } from './plugin'; + +const emailServiceMock: jest.Mocked = { + sendPlainTextEmail: jest.fn(), +}; + +const createEmailServiceMock = () => { + return emailServiceMock; +}; + +const startMock: jest.Mocked = { + isEmailServiceAvailable: jest.fn(), + getEmailService: jest.fn(createEmailServiceMock), +}; + +const createStartMock = () => { + return startMock; +}; + +const notificationsPluginMock: jest.Mocked> = { + setup: jest.fn(), + start: jest.fn(createStartMock) as jest.Mock, + stop: jest.fn(), +}; + +const createNotificationsPluginMock = () => { + return notificationsPluginMock; +}; + +export const notificationsMock = { + createNotificationsPlugin: createNotificationsPluginMock, + createEmailService: createEmailServiceMock, + createStart: createStartMock, + clear: () => { + emailServiceMock.sendPlainTextEmail.mockClear(); + startMock.getEmailService.mockClear(); + startMock.isEmailServiceAvailable.mockClear(); + notificationsPluginMock.setup.mockClear(); + notificationsPluginMock.start.mockClear(); + notificationsPluginMock.stop.mockClear(); + }, +}; diff --git a/x-pack/plugins/notifications/server/plugin.test.ts b/x-pack/plugins/notifications/server/plugin.test.ts new file mode 100644 index 00000000000000..687414becb051b --- /dev/null +++ b/x-pack/plugins/notifications/server/plugin.test.ts @@ -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 { coreMock } from '@kbn/core/server/mocks'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import type { NotificationsConfigType } from './config'; +import { NotificationsPlugin } from './plugin'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import { EmailServiceProvider } from './services/connectors_email_service_provider'; +import { EmailServiceStart } from './services'; + +jest.mock('./services/connectors_email_service_provider'); + +const emailServiceProviderMock = EmailServiceProvider as jest.MockedClass< + typeof EmailServiceProvider +>; + +const validConnectorConfig = { + connectors: { + default: { + email: 'validConnectorId', + }, + }, +}; + +const createNotificationsPlugin = (config: NotificationsConfigType) => { + const context = coreMock.createPluginInitializerContext(config); + const plugin = new NotificationsPlugin(context); + const coreSetup = coreMock.createSetup(); + const coreStart = coreMock.createStart(); + + const actionsSetup = actionsMock.createSetup(); + actionsSetup.isPreconfiguredConnector.mockImplementationOnce( + (connectorId) => connectorId === 'validConnectorId' + ); + const pluginSetup = { + actions: actionsSetup, + licensing: licensingMock.createSetup(), + }; + + const actionsStart = actionsMock.createStart(); + const pluginStart = { + actions: actionsStart, + licensing: licensingMock.createStart(), + }; + + return { + context, + logger: context.logger.get(), + plugin, + coreSetup, + coreStart, + actionsSetup, + pluginSetup, + actionsStart, + pluginStart, + }; +}; + +describe('Notifications Plugin', () => { + beforeEach(() => emailServiceProviderMock.mockClear()); + + it('should create an EmailServiceProvider passing in the configuration and logger from the initializer context', () => { + const { logger } = createNotificationsPlugin(validConnectorConfig); + expect(emailServiceProviderMock).toHaveBeenCalledTimes(1); + expect(emailServiceProviderMock).toHaveBeenCalledWith(validConnectorConfig, logger); + }); + + describe('setup()', () => { + it('should call setup() on the created EmailServiceProvider, passing in the setup plugin dependencies', () => { + const { plugin, coreSetup, pluginSetup } = createNotificationsPlugin(validConnectorConfig); + plugin.setup(coreSetup, pluginSetup); + expect(emailServiceProviderMock.mock.instances[0].setup).toHaveBeenCalledTimes(1); + expect(emailServiceProviderMock.mock.instances[0].setup).toBeCalledWith(pluginSetup); + }); + }); + + describe('start()', () => { + it('should call start() on the created EmailServiceProvider, passing in the setup plugin dependencies', () => { + const { plugin, coreStart, pluginStart } = createNotificationsPlugin(validConnectorConfig); + plugin.start(coreStart, pluginStart); + expect(emailServiceProviderMock.mock.instances[0].start).toHaveBeenCalledTimes(1); + expect(emailServiceProviderMock.mock.instances[0].start).toBeCalledWith(pluginStart); + }); + + it('should return EmailServiceProvider.start() contract as part of its contract', () => { + const { plugin, coreStart, pluginStart } = createNotificationsPlugin(validConnectorConfig); + + const emailStart: EmailServiceStart = { + getEmailService: jest.fn(), + isEmailServiceAvailable: jest.fn(), + }; + + const providerMock = emailServiceProviderMock.mock + .instances[0] as jest.Mocked; + providerMock.start.mockReturnValue(emailStart); + const start = plugin.start(coreStart, pluginStart); + expect(emailServiceProviderMock.mock.instances[0].start).toHaveBeenCalledTimes(1); + expect(emailServiceProviderMock.mock.instances[0].start).toBeCalledWith(pluginStart); + expect(start).toEqual(expect.objectContaining(emailStart)); + }); + }); +}); diff --git a/x-pack/plugins/notifications/server/plugin.ts b/x-pack/plugins/notifications/server/plugin.ts new file mode 100755 index 00000000000000..562db1977a73cc --- /dev/null +++ b/x-pack/plugins/notifications/server/plugin.ts @@ -0,0 +1,40 @@ +/* + * 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 type { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; +import type { + NotificationsPluginSetupDeps, + NotificationsPluginStartDeps, + NotificationsPluginStart, +} from './types'; +import type { NotificationsConfigType } from './config'; +import { EmailServiceProvider } from './services/connectors_email_service_provider'; + +export class NotificationsPlugin implements Plugin { + private emailServiceProvider: EmailServiceProvider; + + constructor(initializerContext: PluginInitializerContext) { + this.emailServiceProvider = new EmailServiceProvider( + initializerContext.config.get(), + initializerContext.logger.get() + ); + } + + public setup(_core: CoreSetup, plugins: NotificationsPluginSetupDeps) { + this.emailServiceProvider.setup(plugins); + } + + public start(_core: CoreStart, plugins: NotificationsPluginStartDeps) { + const emailStartContract = this.emailServiceProvider.start(plugins); + + return { + ...emailStartContract, + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts b/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts new file mode 100644 index 00000000000000..f07b3a3ab34ae6 --- /dev/null +++ b/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts @@ -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 { unsecuredActionsClientMock } from '@kbn/actions-plugin/server/unsecured_actions_client/unsecured_actions_client.mock'; +import { ConnectorsEmailService } from './connectors_email_service'; +import type { PlainTextEmail } from './types'; + +const REQUESTER_ID = 'requesterId'; +const CONNECTOR_ID = 'connectorId'; + +describe('sendPlainTextEmail()', () => { + describe('calls the provided ActionsClient#bulkEnqueueExecution() with the appropriate params', () => { + it(`omits the 'relatedSavedObjects' field if no context is provided`, () => { + const actionsClient = unsecuredActionsClientMock.create(); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient); + const payload: PlainTextEmail = { + to: ['user1@email.com'], + subject: 'This is a notification email', + message: 'With some contents inside.', + }; + + email.sendPlainTextEmail(payload); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith(REQUESTER_ID, [ + { + id: CONNECTOR_ID, + params: { + to: ['user1@email.com'], + subject: 'This is a notification email', + message: 'With some contents inside.', + }, + }, + ]); + }); + + it(`populates the 'relatedSavedObjects' field if context is provided`, () => { + const actionsClient = unsecuredActionsClientMock.create(); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient); + const payload: PlainTextEmail = { + to: ['user1@email.com', 'user2@email.com', 'user3@email.com'], + subject: 'This is a notification email', + message: 'With some contents inside.', + context: { + relatedObjects: [ + { + id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b', + type: 'cases', + namespace: 'space1', + }, + ], + }, + }; + + email.sendPlainTextEmail(payload); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith(REQUESTER_ID, [ + { + id: CONNECTOR_ID, + params: { + to: ['user1@email.com'], + subject: 'This is a notification email', + message: 'With some contents inside.', + }, + relatedSavedObjects: [ + { + id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b', + type: 'cases', + namespace: 'space1', + }, + ], + }, + { + id: CONNECTOR_ID, + params: { + to: ['user2@email.com'], + subject: 'This is a notification email', + message: 'With some contents inside.', + }, + relatedSavedObjects: [ + { + id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b', + type: 'cases', + namespace: 'space1', + }, + ], + }, + { + id: CONNECTOR_ID, + params: { + to: ['user3@email.com'], + subject: 'This is a notification email', + message: 'With some contents inside.', + }, + relatedSavedObjects: [ + { + id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b', + type: 'cases', + namespace: 'space1', + }, + ], + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service.ts b/x-pack/plugins/notifications/server/services/connectors_email_service.ts new file mode 100755 index 00000000000000..55586dd05b0783 --- /dev/null +++ b/x-pack/plugins/notifications/server/services/connectors_email_service.ts @@ -0,0 +1,30 @@ +/* + * 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 type { IUnsecuredActionsClient } from '@kbn/actions-plugin/server'; +import type { EmailService, PlainTextEmail } from './types'; + +export class ConnectorsEmailService implements EmailService { + constructor( + private requesterId: string, + private connectorId: string, + private actionsClient: IUnsecuredActionsClient + ) {} + + async sendPlainTextEmail(params: PlainTextEmail): Promise { + const actions = params.to.map((to) => ({ + id: this.connectorId, + params: { + to: [to], + subject: params.subject, + message: params.message, + }, + relatedSavedObjects: params.context?.relatedObjects, + })); + return await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions); + } +} diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts new file mode 100644 index 00000000000000..6c36f94db1a7cc --- /dev/null +++ b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts @@ -0,0 +1,243 @@ +/* + * 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 { loggerMock } from '@kbn/logging-mocks'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import { LicensedEmailService } from './licensed_email_service'; +import { EmailServiceProvider } from './connectors_email_service_provider'; +import { ConnectorsEmailService } from './connectors_email_service'; +import { PLUGIN_ID } from '../../common'; + +jest.mock('./licensed_email_service'); +jest.mock('./connectors_email_service'); + +const licensedEmailServiceMock = LicensedEmailService as jest.MockedClass< + typeof LicensedEmailService +>; +const connectorsEmailServiceMock = ConnectorsEmailService as jest.MockedClass< + typeof ConnectorsEmailService +>; + +const missingConnectorConfig = { + connectors: { + default: {}, + }, +}; + +const invalidConnectorConfig = { + connectors: { + default: { + email: 'someUnexistingConnectorId', + }, + }, +}; + +const validConnectorConfig = { + connectors: { + default: { + email: 'validConnectorId', + }, + }, +}; + +describe('ConnectorsEmailServiceProvider', () => { + const logger = loggerMock.create(); + const actionsSetup = actionsMock.createSetup(); + actionsSetup.isPreconfiguredConnector.mockImplementation( + (connectorId) => connectorId === 'validConnectorId' + ); + + beforeEach(() => { + loggerMock.clear(logger); + licensedEmailServiceMock.mockClear(); + connectorsEmailServiceMock.mockClear(); + }); + + it('implements the IEmailServiceProvider interface', () => { + const serviceProvider = new EmailServiceProvider(validConnectorConfig, loggerMock.create()); + expect(serviceProvider.setup).toBeInstanceOf(Function); + expect(serviceProvider.start).toBeInstanceOf(Function); + }); + + describe('setup()', () => { + it('should log a warning if Actions or Licensing plugins are not available', () => { + const serviceProvider = new EmailServiceProvider(validConnectorConfig, logger); + serviceProvider.setup({ + actions: actionsSetup, + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + `Email Service Error: 'actions' and 'licensing' plugins are required.` + ); + // eslint-disable-next-line dot-notation + expect(serviceProvider['setupSuccessful']).toEqual(false); + }); + + it('should log a warning if no default email connector has been defined', () => { + const serviceProvider = new EmailServiceProvider(missingConnectorConfig, logger); + serviceProvider.setup({ + actions: actionsSetup, + licensing: licensingMock.createSetup(), + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + `Email Service Error: Email connector not specified.` + ); + // eslint-disable-next-line dot-notation + expect(serviceProvider['setupSuccessful']).toEqual(false); + }); + + it('should log a warning if the specified email connector is not a preconfigured connector', () => { + const serviceProvider = new EmailServiceProvider(invalidConnectorConfig, logger); + serviceProvider.setup({ + actions: actionsSetup, + licensing: licensingMock.createSetup(), + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + `Email Service Error: Unexisting email connector 'someUnexistingConnectorId' specified.` + ); + // eslint-disable-next-line dot-notation + expect(serviceProvider['setupSuccessful']).toEqual(false); + }); + + it('should not log a warning if required plugins are present and the specified email connector is valid', () => { + const serviceProvider = new EmailServiceProvider(validConnectorConfig, logger); + serviceProvider.setup({ + actions: actionsSetup, + licensing: licensingMock.createSetup(), + }); + + expect(logger.warn).not.toHaveBeenCalled(); + // eslint-disable-next-line dot-notation + expect(serviceProvider['setupSuccessful']).toEqual(true); + }); + }); + + describe('start()', () => { + it('returns an object that implements the EmailServiceStart contract', () => { + const serviceProvider = new EmailServiceProvider(missingConnectorConfig, logger); + const start = serviceProvider.start({}); + expect(start.getEmailService).toBeInstanceOf(Function); + expect(start.isEmailServiceAvailable).toBeInstanceOf(Function); + }); + + describe('if setup has not been run', () => { + it('the start contract methods fail accordingly', () => { + const serviceProvider = new EmailServiceProvider(missingConnectorConfig, logger); + const start = serviceProvider.start({}); + expect(start.isEmailServiceAvailable()).toEqual(false); + expect(() => { + start.getEmailService(); + }).toThrowErrorMatchingInlineSnapshot(`"Email Service Error: setup() has not been run"`); + }); + }); + + describe('if setup() did not complete successfully', () => { + it('the start contract methods fail accordingly', () => { + const serviceProvider = new EmailServiceProvider(invalidConnectorConfig, logger); + serviceProvider.setup({ + actions: actionsSetup, + licensing: licensingMock.createSetup(), + }); + const start = serviceProvider.start({ + actions: actionsMock.createStart(), + licensing: licensingMock.createStart(), + }); + expect(start.isEmailServiceAvailable()).toEqual(false); + expect(() => { + start.getEmailService(); + }).toThrowErrorMatchingInlineSnapshot( + `"Email Service Error: Unexisting email connector 'someUnexistingConnectorId' specified."` + ); + }); + }); + + describe('if setup() did complete successfully and Action and Licensing plugin start contracts are available', () => { + it('attempts to build an UnsecuredActionsClient', () => { + const serviceProvider = new EmailServiceProvider(validConnectorConfig, logger); + const actionsStart = actionsMock.createStart(); + + serviceProvider.setup({ + actions: actionsSetup, + licensing: licensingMock.createSetup(), + }); + serviceProvider.start({ + actions: actionsStart, + licensing: licensingMock.createStart(), + }); + expect(actionsStart.getUnsecuredActionsClient).toHaveBeenCalledTimes(1); + }); + + describe('if getUnsecuredActionsClient() throws an Exception', () => { + it('catches the exception, and the start contract methods fail accordingly', () => { + const serviceProvider = new EmailServiceProvider(validConnectorConfig, logger); + const actionsStart = actionsMock.createStart(); + actionsStart.getUnsecuredActionsClient.mockImplementation(() => { + throw new Error('Something went terribly wrong.'); + }); + + serviceProvider.setup({ + actions: actionsSetup, + licensing: licensingMock.createSetup(), + }); + const start = serviceProvider.start({ + actions: actionsStart, + licensing: licensingMock.createStart(), + }); + + expect(start.isEmailServiceAvailable()).toEqual(false); + expect(() => { + start.getEmailService(); + }).toThrowErrorMatchingInlineSnapshot( + `"Email Service Error: Something went terribly wrong."` + ); + }); + }); + + describe('if getUnsecuredActionsClient() returns an UnsecuredActionsClient', () => { + it('returns a start contract that provides valid EmailService', () => { + const serviceProvider = new EmailServiceProvider(validConnectorConfig, logger); + const licensingStart = licensingMock.createStart(); + const actionsStart = actionsMock.createStart(); + + serviceProvider.setup({ + actions: actionsSetup, + licensing: licensingMock.createSetup(), + }); + const start = serviceProvider.start({ + actions: actionsStart, + licensing: licensingStart, + }); + + expect(start.isEmailServiceAvailable()).toEqual(true); + const email = start.getEmailService(); + expect(email).toBeInstanceOf(LicensedEmailService); + expect(licensedEmailServiceMock).toHaveBeenCalledTimes(1); + + expect(licensedEmailServiceMock).toHaveBeenCalledWith( + connectorsEmailServiceMock.mock.instances[0], + licensingStart.license$, + 'platinum', + expect.objectContaining({ debug: expect.any(Function), warn: expect.any(Function) }) + ); + + expect(connectorsEmailServiceMock).toHaveBeenCalledTimes(1); + expect(connectorsEmailServiceMock).toHaveBeenCalledWith( + PLUGIN_ID, + validConnectorConfig.connectors.default.email, + actionsStart.getUnsecuredActionsClient() + ); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts new file mode 100755 index 00000000000000..f034116eb701ce --- /dev/null +++ b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts @@ -0,0 +1,98 @@ +/* + * 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 type { Logger } from '@kbn/core/server'; +import { PluginSetupContract, PluginStartContract } from '@kbn/actions-plugin/server'; +import { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/server'; +import type { EmailService, EmailServiceStart, IEmailServiceProvider } from './types'; +import type { NotificationsConfigType } from '../config'; +import { LicensedEmailService } from './licensed_email_service'; +import { ConnectorsEmailService } from './connectors_email_service'; +import { PLUGIN_ID } from '../../common'; + +const MINIMUM_LICENSE = 'platinum'; + +export interface EmailServiceSetupDeps { + actions?: PluginSetupContract; + licensing?: LicensingPluginSetup; +} + +export interface EmailServiceStartDeps { + actions?: PluginStartContract; + licensing?: LicensingPluginStart; +} + +export class EmailServiceProvider + implements IEmailServiceProvider +{ + private setupSuccessful: boolean; + private setupError: string; + + constructor(private config: NotificationsConfigType, private logger: Logger) { + this.setupSuccessful = false; + this.setupError = 'Email Service Error: setup() has not been run'; + } + + public setup(plugins: EmailServiceSetupDeps) { + const { actions, licensing } = plugins; + + if (!actions || !licensing) { + return this._registerServiceError(`Error: 'actions' and 'licensing' plugins are required.`); + } + + const emailConnector = this.config.connectors?.default?.email; + if (!emailConnector) { + return this._registerServiceError('Error: Email connector not specified.'); + } + + if (!actions.isPreconfiguredConnector(emailConnector)) { + return this._registerServiceError( + `Error: Unexisting email connector '${emailConnector}' specified.` + ); + } + + this.setupSuccessful = true; + this.setupError = ''; + } + + public start(plugins: EmailServiceStartDeps): EmailServiceStart { + const { actions, licensing } = plugins; + + let email: EmailService; + if (this.setupSuccessful && actions && licensing) { + const emailConnector = this.config.connectors!.default!.email!; + + try { + const unsecuredActionsClient = actions.getUnsecuredActionsClient(); + email = new LicensedEmailService( + new ConnectorsEmailService(PLUGIN_ID, emailConnector, unsecuredActionsClient), + licensing.license$, + MINIMUM_LICENSE, + this.logger + ); + } catch (err) { + this._registerServiceError(err); + } + } + + return { + isEmailServiceAvailable: () => !!email, + getEmailService: () => { + if (!email) { + throw new Error(this.setupError); + } + return email; + }, + }; + } + + private _registerServiceError(error: string) { + const message = `Email Service ${error}`; + this.setupError = message; + this.logger.warn(message); + } +} diff --git a/x-pack/plugins/notifications/server/services/index.ts b/x-pack/plugins/notifications/server/services/index.ts new file mode 100644 index 00000000000000..f0ad8abb4885d9 --- /dev/null +++ b/x-pack/plugins/notifications/server/services/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export type { EmailService, EmailServiceStart, PlainTextEmail } from './types'; +export type { + EmailServiceSetupDeps, + EmailServiceStartDeps, +} from './connectors_email_service_provider'; diff --git a/x-pack/plugins/notifications/server/services/licensed_email_service.test.ts b/x-pack/plugins/notifications/server/services/licensed_email_service.test.ts new file mode 100644 index 00000000000000..ac196ebb7321b0 --- /dev/null +++ b/x-pack/plugins/notifications/server/services/licensed_email_service.test.ts @@ -0,0 +1,125 @@ +/* + * 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 { Subject } from 'rxjs'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { LicensedEmailService } from './licensed_email_service'; +import type { ILicense } from '@kbn/licensing-plugin/server'; +import type { EmailService, PlainTextEmail } from './types'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const emailServiceMock: EmailService = { + sendPlainTextEmail: jest.fn(), +}; + +const validLicense = licensingMock.createLicenseMock(); +const invalidLicense = licensingMock.createLicenseMock(); +invalidLicense.type = 'basic'; +invalidLicense.check = jest.fn(() => ({ + state: 'invalid', + message: 'This is an invalid testing license', +})) as unknown as any; + +const someEmail: PlainTextEmail = { + to: ['user1@email.com'], + subject: 'Some subject', + message: 'Some message', +}; + +describe('LicensedEmailService', () => { + const logger = loggerMock.create(); + + beforeEach(() => loggerMock.clear(logger)); + it('observes license$ changes and logs info or warning messages accordingly', () => { + const license$ = new Subject(); + new LicensedEmailService(emailServiceMock, license$, 'platinum', logger); + license$.next(invalidLicense); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith('This is an invalid testing license'); + + license$.next(validLicense); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith( + 'Your current license allows sending email notifications' + ); + }); + + describe('sendPlainTextEmail()', () => { + it('does not call the underlying email service until the license is determined and valid', async () => { + const license$ = new Subject(); + const email = new LicensedEmailService(emailServiceMock, license$, 'platinum', logger); + + email.sendPlainTextEmail(someEmail); + expect(emailServiceMock.sendPlainTextEmail).not.toHaveBeenCalled(); + license$.next(validLicense); + + await delay(1); + + expect(emailServiceMock.sendPlainTextEmail).toHaveBeenCalledTimes(1); + expect(emailServiceMock.sendPlainTextEmail).toHaveBeenCalledWith(someEmail); + }); + + it('does not call the underlying email service if the license is invalid', async () => { + const license$ = new Subject(); + const email = new LicensedEmailService(emailServiceMock, license$, 'platinum', logger); + license$.next(invalidLicense); + + try { + await email.sendPlainTextEmail(someEmail); + } catch (err) { + expect(err.message).toEqual( + 'The current license does not allow sending email notifications' + ); + return; + } + + expect('it should have thrown').toEqual('but it did not'); + }); + + it('does not log a warning for every email attempt, but rather for every license change', async () => { + const license$ = new Subject(); + const email = new LicensedEmailService(emailServiceMock, license$, 'platinum', logger); + license$.next(invalidLicense); + license$.next(validLicense); + license$.next(invalidLicense); + + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledTimes(2); + + let emailsOk = 0; + let emailsKo = 0; + const silentSend = async () => { + try { + await email.sendPlainTextEmail(someEmail); + emailsOk++; + } catch (err) { + emailsKo++; + } + }; + + await silentSend(); + await silentSend(); + await silentSend(); + await silentSend(); + license$.next(validLicense); + await silentSend(); + await silentSend(); + await silentSend(); + await silentSend(); + + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledTimes(2); + expect(emailsKo).toEqual(4); + expect(emailsOk).toEqual(4); + }); + }); +}); diff --git a/x-pack/plugins/notifications/server/services/licensed_email_service.ts b/x-pack/plugins/notifications/server/services/licensed_email_service.ts new file mode 100644 index 00000000000000..63fc6f8d13df36 --- /dev/null +++ b/x-pack/plugins/notifications/server/services/licensed_email_service.ts @@ -0,0 +1,48 @@ +/* + * 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 type { Logger } from '@kbn/logging'; +import type { ILicense, LicenseType } from '@kbn/licensing-plugin/server'; +import { firstValueFrom, map, type Observable, ReplaySubject, type Subject } from 'rxjs'; +import type { EmailService, PlainTextEmail } from './types'; +import { PLUGIN_ID } from '../../common'; + +export class LicensedEmailService implements EmailService { + private validLicense$: Subject = new ReplaySubject(1); + + constructor( + private emailService: EmailService, + license$: Observable, + private minimumLicense: LicenseType, + private logger: Logger + ) { + // no need to explicitly unsubscribe as the license$ observable already completes on stop() + license$.pipe(map((license) => this.checkValidLicense(license))).subscribe(this.validLicense$); + } + + async sendPlainTextEmail(payload: PlainTextEmail): Promise { + if (await firstValueFrom(this.validLicense$, { defaultValue: false })) { + await this.emailService.sendPlainTextEmail(payload); + } else { + throw new Error('The current license does not allow sending email notifications'); + } + } + + private checkValidLicense(license: ILicense): boolean { + const licenseCheck = license.check(PLUGIN_ID, this.minimumLicense); + + if (licenseCheck.state === 'valid') { + this.logger.debug('Your current license allows sending email notifications'); + return true; + } + + this.logger.warn( + licenseCheck.message || 'The current license does not allow sending email notifications' + ); + return false; + } +} diff --git a/x-pack/plugins/notifications/server/services/types.ts b/x-pack/plugins/notifications/server/services/types.ts new file mode 100755 index 00000000000000..798b4d7e246996 --- /dev/null +++ b/x-pack/plugins/notifications/server/services/types.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +export interface EmailService { + sendPlainTextEmail(payload: PlainTextEmail): Promise; +} + +export interface EmailServiceStart { + isEmailServiceAvailable(): boolean; + getEmailService(): EmailService; +} + +export interface IEmailServiceProvider { + setup(setupDeps: T): void; + start(startDeps: U): EmailServiceStart; +} + +export interface RelatedSavedObject { + id: string; + type: string; + namespace?: string; // namespace is undefined for the spaceId 'default' +} + +export interface PlainTextEmail { + to: string[]; + subject: string; + message: string; + context?: { + relatedObjects?: RelatedSavedObject[]; + }; +} diff --git a/x-pack/plugins/notifications/server/types.ts b/x-pack/plugins/notifications/server/types.ts new file mode 100755 index 00000000000000..e7132a33cbb198 --- /dev/null +++ b/x-pack/plugins/notifications/server/types.ts @@ -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 type { EmailServiceStart, EmailServiceSetupDeps, EmailServiceStartDeps } from './services'; + +// The 'notifications' plugin is currently only exposing an email service. +// If we want to expose other services in the future, we should update these types accordingly +export type NotificationsPluginSetupDeps = EmailServiceSetupDeps; +export type NotificationsPluginStartDeps = EmailServiceStartDeps; +export type NotificationsPluginStart = EmailServiceStart; diff --git a/x-pack/plugins/notifications/tsconfig.json b/x-pack/plugins/notifications/tsconfig.json new file mode 100644 index 00000000000000..6f2c186803b0cf --- /dev/null +++ b/x-pack/plugins/notifications/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*", + // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 + "server/**/*.json", + "public/**/*", + "common/**/*" + ], + "kbn_references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../actions/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" } + ] +}