[Alerting] notifies user when security is enabled but TLS is not (#60270) (#61141)

This PR:
1. Adds a callout on the Alerting UI when security is enabled but TLS is not
2. Cleans up displayed error message when creation fails due to TLS being switched off
This commit is contained in:
Gidi Meir Morris 2020-03-24 21:24:37 +00:00 committed by GitHub
parent c8590d95b8
commit b5e271f126
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 809 additions and 103 deletions

View file

@ -25,6 +25,7 @@ import {
AppMountParameters,
CoreStart,
IUiSettingsClient,
DocLinksStart,
ToastsSetup,
} from '../../../src/core/public';
import { DataPublicPluginStart } from '../../../src/plugins/data/public';
@ -45,6 +46,7 @@ export interface AlertingExampleComponentParams {
data: DataPublicPluginStart;
charts: ChartsPluginStart;
uiSettings: IUiSettingsClient;
docLinks: DocLinksStart;
toastNotifications: ToastsSetup;
}
@ -88,7 +90,7 @@ const AlertingExampleApp = (deps: AlertingExampleComponentParams) => {
};
export const renderApp = (
{ application, notifications, http, uiSettings }: CoreStart,
{ application, notifications, http, uiSettings, docLinks }: CoreStart,
deps: AlertingExamplePublicStartDeps,
{ appBasePath, element }: AppMountParameters
) => {
@ -99,6 +101,7 @@ export const renderApp = (
toastNotifications={notifications.toasts}
http={http}
uiSettings={uiSettings}
docLinks={docLinks}
{...deps}
/>,
element

View file

@ -33,6 +33,7 @@ export const CreateAlert = ({
triggers_actions_ui,
charts,
uiSettings,
docLinks,
data,
toastNotifications,
}: AlertingExampleComponentParams) => {
@ -56,6 +57,7 @@ export const CreateAlert = ({
alertTypeRegistry: triggers_actions_ui.alertTypeRegistry,
toastNotifications,
uiSettings,
docLinks,
charts,
dataFieldsFormats: data.fieldFormats,
}}

View file

@ -172,6 +172,7 @@ export class ApmPlugin
<AlertsContextProvider
value={{
http: core.http,
docLinks: core.docLinks,
toastNotifications: core.notifications.toasts,
actionTypeRegistry: plugins.triggers_actions_ui.actionTypeRegistry,
alertTypeRegistry: plugins.triggers_actions_ui.alertTypeRegistry

View file

@ -17,6 +17,7 @@ export const UptimeAlertsContextProvider: React.FC = ({ children }) => {
notifications,
triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry },
uiSettings,
docLinks,
},
} = useKibana();
@ -26,6 +27,7 @@ export const UptimeAlertsContextProvider: React.FC = ({ children }) => {
actionTypeRegistry,
alertTypeRegistry,
charts,
docLinks,
dataFieldsFormats: fieldFormats,
http,
toastNotifications: notifications?.toasts,

View file

@ -10,4 +10,13 @@ export * from './alert_instance';
export * from './alert_task_instance';
export * from './alert_navigation';
export interface ActionGroup {
id: string;
name: string;
}
export interface AlertingFrameworkHealth {
isSufficientlySecure: boolean;
}
export const BASE_ALERT_API_PATH = '/api/alert';

View file

@ -46,6 +46,7 @@ import {
unmuteAllAlertRoute,
muteAlertInstanceRoute,
unmuteAlertInstanceRoute,
healthRoute,
} from './routes';
import { LicensingPluginSetup } from '../../licensing/server';
import {
@ -173,6 +174,7 @@ export class AlertingPlugin {
unmuteAllAlertRoute(router, this.licenseState);
muteAlertInstanceRoute(router, this.licenseState);
unmuteAlertInstanceRoute(router, this.licenseState);
healthRoute(router, this.licenseState);
return {
registerType: alertTypeRegistry.register.bind(alertTypeRegistry),

View file

@ -10,13 +10,14 @@ import { httpServerMock } from '../../../../../src/core/server/mocks';
import { alertsClientMock } from '../alerts_client.mock';
export function mockHandlerArguments(
{ alertsClient, listTypes: listTypesRes = [] }: any,
{ alertsClient, listTypes: listTypesRes = [], elasticsearch }: any,
req: any,
res?: Array<MethodKeysOf<KibanaResponseFactory>>
): [RequestHandlerContext, KibanaRequest<any, any, any, any>, KibanaResponseFactory] {
const listTypes = jest.fn(() => listTypesRes);
return [
({
core: { elasticsearch },
alerting: {
listTypes,
getAlertsClient() {

View file

@ -15,6 +15,7 @@ import {
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { validateDurationSchema } from '../lib';
import { handleDisabledApiKeysError } from './lib/error_handler';
import { Alert, BASE_ALERT_API_PATH } from '../types';
export const bodySchema = schema.object({
@ -50,22 +51,24 @@ export const createAlertRoute = (router: IRouter, licenseState: LicenseState) =>
tags: ['access:alerting-all'],
},
},
router.handleLegacyErrors(async function(
context: RequestHandlerContext,
req: KibanaRequest<any, any, TypeOf<typeof bodySchema>, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
verifyApiAccess(licenseState);
handleDisabledApiKeysError(
router.handleLegacyErrors(async function(
context: RequestHandlerContext,
req: KibanaRequest<any, any, TypeOf<typeof bodySchema>, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
verifyApiAccess(licenseState);
if (!context.alerting) {
return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' });
}
const alertsClient = context.alerting.getAlertsClient();
const alert = req.body;
const alertRes: Alert = await alertsClient.create({ data: alert });
return res.ok({
body: alertRes,
});
})
if (!context.alerting) {
return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' });
}
const alertsClient = context.alerting.getAlertsClient();
const alert = req.body;
const alertRes: Alert = await alertsClient.create({ data: alert });
return res.ok({
body: alertRes,
});
})
)
);
};

View file

@ -15,6 +15,7 @@ import {
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { BASE_ALERT_API_PATH } from '../../common';
import { handleDisabledApiKeysError } from './lib/error_handler';
const paramSchema = schema.object({
id: schema.string(),
@ -31,19 +32,21 @@ export const enableAlertRoute = (router: IRouter, licenseState: LicenseState) =>
tags: ['access:alerting-all'],
},
},
router.handleLegacyErrors(async function(
context: RequestHandlerContext,
req: KibanaRequest<TypeOf<typeof paramSchema>, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
verifyApiAccess(licenseState);
if (!context.alerting) {
return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' });
}
const alertsClient = context.alerting.getAlertsClient();
const { id } = req.params;
await alertsClient.enable({ id });
return res.noContent();
})
handleDisabledApiKeysError(
router.handleLegacyErrors(async function(
context: RequestHandlerContext,
req: KibanaRequest<TypeOf<typeof paramSchema>, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
verifyApiAccess(licenseState);
if (!context.alerting) {
return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' });
}
const alertsClient = context.alerting.getAlertsClient();
const { id } = req.params;
await alertsClient.enable({ id });
return res.noContent();
})
)
);
};

View file

@ -0,0 +1,171 @@
/*
* 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 { healthRoute } from './health';
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks';
import { verifyApiAccess } from '../lib/license_api_access';
import { mockLicenseState } from '../lib/license_state.mock';
jest.mock('../lib/license_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
});
describe('healthRoute', () => {
it('registers the route', async () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const [config] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/api/alert/_health"`);
});
it('queries the usage api', async () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
elasticsearch.adminClient.callAsInternalUser.mockReturnValue(Promise.resolve({}));
const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
expect(elasticsearch.adminClient.callAsInternalUser.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"transport.request",
Object {
"method": "GET",
"path": "/_xpack/usage",
},
]
`);
});
it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
elasticsearch.adminClient.callAsInternalUser.mockReturnValue(Promise.resolve({}));
const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"isSufficientlySecure": true,
},
}
`);
});
it('evaluates missing security http info from the usage api to mean that the security plugin is disbled', async () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
elasticsearch.adminClient.callAsInternalUser.mockReturnValue(Promise.resolve({ security: {} }));
const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"isSufficientlySecure": true,
},
}
`);
});
it('evaluates security enabled, and missing ssl info from the usage api to mean that the user cannot generate keys', async () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
elasticsearch.adminClient.callAsInternalUser.mockReturnValue(
Promise.resolve({ security: { enabled: true } })
);
const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"isSufficientlySecure": false,
},
}
`);
});
it('evaluates security enabled, SSL info present but missing http info from the usage api to mean that the user cannot generate keys', async () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
elasticsearch.adminClient.callAsInternalUser.mockReturnValue(
Promise.resolve({ security: { enabled: true, ssl: {} } })
);
const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"isSufficientlySecure": false,
},
}
`);
});
it('evaluates security and tls enabled to mean that the user can generate keys', async () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
elasticsearch.adminClient.callAsInternalUser.mockReturnValue(
Promise.resolve({ security: { enabled: true, ssl: { http: { enabled: true } } } })
);
const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"isSufficientlySecure": true,
},
}
`);
});
});

View file

@ -0,0 +1,67 @@
/*
* 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 {
IRouter,
RequestHandlerContext,
KibanaRequest,
IKibanaResponse,
KibanaResponseFactory,
} from 'kibana/server';
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { AlertingFrameworkHealth } from '../types';
interface XPackUsageSecurity {
security?: {
enabled?: boolean;
ssl?: {
http?: {
enabled?: boolean;
};
};
};
}
export function healthRoute(router: IRouter, licenseState: LicenseState) {
router.get(
{
path: '/api/alert/_health',
validate: false,
},
router.handleLegacyErrors(async function(
context: RequestHandlerContext,
req: KibanaRequest<any, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
verifyApiAccess(licenseState);
try {
const {
security: {
enabled: isSecurityEnabled = false,
ssl: { http: { enabled: isTLSEnabled = false } = {} } = {},
} = {},
}: XPackUsageSecurity = await context.core.elasticsearch.adminClient
// `transport.request` is potentially unsafe when combined with untrusted user input.
// Do not augment with such input.
.callAsInternalUser('transport.request', {
method: 'GET',
path: '/_xpack/usage',
});
const frameworkHealth: AlertingFrameworkHealth = {
isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled),
};
return res.ok({
body: frameworkHealth,
});
} catch (error) {
return res.badRequest({ body: error });
}
})
);
}

View file

@ -18,3 +18,4 @@ export { muteAlertInstanceRoute } from './mute_instance';
export { unmuteAlertInstanceRoute } from './unmute_instance';
export { muteAllAlertRoute } from './mute_all';
export { unmuteAllAlertRoute } from './unmute_all';
export { healthRoute } from './health';

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 { i18n } from '@kbn/i18n';
import {
RequestHandler,
KibanaRequest,
KibanaResponseFactory,
RequestHandlerContext,
RouteMethod,
} from 'kibana/server';
export function handleDisabledApiKeysError<P, Q, B>(
handler: RequestHandler<P, Q, B>
): RequestHandler<P, Q, B> {
return async (
context: RequestHandlerContext,
request: KibanaRequest<P, Q, B, RouteMethod>,
response: KibanaResponseFactory
) => {
try {
return await handler(context, request, response);
} catch (e) {
if (isApiKeyDisabledError(e)) {
return response.badRequest({
body: new Error(
i18n.translate('xpack.alerting.api.error.disabledApiKeys', {
defaultMessage: 'Alerting relies upon API keys which appear to be disabled',
})
),
});
}
throw e;
}
};
}
export function isApiKeyDisabledError(e: Error) {
return e?.message?.includes('api keys are not enabled') ?? false;
}
export function isSecurityPluginDisabledError(e: Error) {
return e?.message?.includes('no handler found') ?? false;
}

View file

@ -15,6 +15,7 @@ import {
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { validateDurationSchema } from '../lib';
import { handleDisabledApiKeysError } from './lib/error_handler';
import { BASE_ALERT_API_PATH } from '../../common';
const paramSchema = schema.object({
@ -52,24 +53,26 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) =>
tags: ['access:alerting-all'],
},
},
router.handleLegacyErrors(async function(
context: RequestHandlerContext,
req: KibanaRequest<TypeOf<typeof paramSchema>, any, TypeOf<typeof bodySchema>, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
verifyApiAccess(licenseState);
if (!context.alerting) {
return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' });
}
const alertsClient = context.alerting.getAlertsClient();
const { id } = req.params;
const { name, actions, params, schedule, tags, throttle } = req.body;
return res.ok({
body: await alertsClient.update({
id,
data: { name, actions, params, schedule, tags, throttle },
}),
});
})
handleDisabledApiKeysError(
router.handleLegacyErrors(async function(
context: RequestHandlerContext,
req: KibanaRequest<TypeOf<typeof paramSchema>, any, TypeOf<typeof bodySchema>, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
verifyApiAccess(licenseState);
if (!context.alerting) {
return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' });
}
const alertsClient = context.alerting.getAlertsClient();
const { id } = req.params;
const { name, actions, params, schedule, tags, throttle } = req.body;
return res.ok({
body: await alertsClient.update({
id,
data: { name, actions, params, schedule, tags, throttle },
}),
});
})
)
);
};

View file

@ -15,6 +15,7 @@ import {
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { BASE_ALERT_API_PATH } from '../../common';
import { handleDisabledApiKeysError } from './lib/error_handler';
const paramSchema = schema.object({
id: schema.string(),
@ -31,19 +32,21 @@ export const updateApiKeyRoute = (router: IRouter, licenseState: LicenseState) =
tags: ['access:alerting-all'],
},
},
router.handleLegacyErrors(async function(
context: RequestHandlerContext,
req: KibanaRequest<TypeOf<typeof paramSchema>, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
verifyApiAccess(licenseState);
if (!context.alerting) {
return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' });
}
const alertsClient = context.alerting.getAlertsClient();
const { id } = req.params;
await alertsClient.updateApiKey({ id });
return res.noContent();
})
handleDisabledApiKeysError(
router.handleLegacyErrors(async function(
context: RequestHandlerContext,
req: KibanaRequest<TypeOf<typeof paramSchema>, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
verifyApiAccess(licenseState);
if (!context.alerting) {
return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' });
}
const alertsClient = context.alerting.getAlertsClient();
const { id } = req.params;
await alertsClient.updateApiKey({ id });
return res.noContent();
})
)
);
};

View file

@ -35,6 +35,7 @@ export const AlertFlyout = (props: Props) => {
},
toastNotifications: services.notifications?.toasts,
http: services.http,
docLinks: services.docLinks,
actionTypeRegistry: triggersActionsUI.actionTypeRegistry,
alertTypeRegistry: triggersActionsUI.alertTypeRegistry,
}}

View file

@ -660,6 +660,7 @@ const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState<boolean>(false);
alertTypeRegistry: triggers_actions_ui.alertTypeRegistry,
toastNotifications: toasts,
uiSettings,
docLinks,
charts,
dataFieldsFormats,
metadata: { test: 'some value', fields: ['test'] },
@ -697,6 +698,7 @@ export interface AlertsContextValue<MetaData = Record<string, any>> {
alertTypeRegistry: TypeRegistry<AlertTypeModel>;
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
uiSettings?: IUiSettingsClient;
docLinks: DocLinksStart;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
@ -714,6 +716,7 @@ export interface AlertsContextValue<MetaData = Record<string, any>> {
|alertTypeRegistry|Registry for alert types.|
|actionTypeRegistry|Registry for action types.|
|uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.|
|docLinks|Documentation Links, needed to link to the documentation from informational callouts.|
|toastNotifications|Toast messages.|
|charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.|
|dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.|
@ -1322,6 +1325,7 @@ export interface AlertsContextValue {
alertTypeRegistry: TypeRegistry<AlertTypeModel>;
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
uiSettings?: IUiSettingsClient;
docLinks: DocLinksStart;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
@ -1338,6 +1342,7 @@ export interface AlertsContextValue {
|alertTypeRegistry|Registry for alert types.|
|actionTypeRegistry|Registry for action types.|
|uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.|
|docLinks|Documentation Links, needed to link to the documentation from informational callouts.|
|toastNotifications|Toast messages.|
|charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.|
|dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.|

View file

@ -0,0 +1,78 @@
/*
* 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 React, { Fragment } from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { AlertActionSecurityCallOut } from './alert_action_security_call_out';
import { EuiCallOut, EuiButton } from '@elastic/eui';
import { act } from 'react-dom/test-utils';
import { httpServiceMock } from '../../../../../../src/core/public/mocks';
const docLinks = { ELASTIC_WEBSITE_URL: 'elastic.co/', DOC_LINK_VERSION: 'current' };
const http = httpServiceMock.createStartContract();
describe('alert action security call out', () => {
let useEffect: any;
const mockUseEffect = () => {
// make react execute useEffects despite shallow rendering
useEffect.mockImplementationOnce((f: Function) => f());
};
beforeEach(() => {
jest.resetAllMocks();
useEffect = jest.spyOn(React, 'useEffect');
mockUseEffect();
});
test('renders nothing while health is loading', async () => {
http.get.mockImplementationOnce(() => new Promise(() => {}));
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(
<AlertActionSecurityCallOut action="created" http={http} docLinks={docLinks} />
);
});
expect(component?.is(Fragment)).toBeTruthy();
expect(component?.html()).toBe('');
});
test('renders nothing if keys are enabled', async () => {
http.get.mockResolvedValue({ isSufficientlySecure: true });
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(
<AlertActionSecurityCallOut action="created" http={http} docLinks={docLinks} />
);
});
expect(component?.is(Fragment)).toBeTruthy();
expect(component?.html()).toBe('');
});
test('renders the callout if keys are disabled', async () => {
http.get.mockResolvedValue({ isSufficientlySecure: false });
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(
<AlertActionSecurityCallOut action="creation" http={http} docLinks={docLinks} />
);
});
expect(component?.find(EuiCallOut).prop('title')).toMatchInlineSnapshot(
`"Alert creation requires TLS between Elasticsearch and Kibana."`
);
expect(component?.find(EuiButton).prop('href')).toMatchInlineSnapshot(
`"elastic.co/guide/en/kibana/current/configuring-tls.html"`
);
});
});

View file

@ -0,0 +1,78 @@
/*
* 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 React, { Fragment } from 'react';
import { Option, none, some, fold, filter } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { DocLinksStart, HttpSetup } from 'kibana/public';
import { AlertingFrameworkHealth } from '../../types';
import { health } from '../lib/alert_api';
interface Props {
docLinks: Pick<DocLinksStart, 'ELASTIC_WEBSITE_URL' | 'DOC_LINK_VERSION'>;
action: string;
http: HttpSetup;
}
export const AlertActionSecurityCallOut: React.FunctionComponent<Props> = ({
http,
action,
docLinks,
}) => {
const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
const [alertingHealth, setAlertingHealth] = React.useState<Option<AlertingFrameworkHealth>>(none);
React.useEffect(() => {
async function fetchSecurityConfigured() {
setAlertingHealth(some(await health({ http })));
}
fetchSecurityConfigured();
}, [http]);
return pipe(
alertingHealth,
filter(healthCheck => !healthCheck.isSufficientlySecure),
fold(
() => <Fragment />,
() => (
<Fragment>
<EuiCallOut
title={i18n.translate(
'xpack.triggersActionsUI.components.alertActionSecurityCallOut.tlsDisabledTitle',
{
defaultMessage: 'Alert {action} requires TLS between Elasticsearch and Kibana.',
values: {
action,
},
}
)}
color="warning"
size="s"
iconType="iInCircle"
>
<EuiButton
color="warning"
href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`}
>
<FormattedMessage
id="xpack.triggersActionsUI.components.alertActionSecurityCallOut.enableTlsCta"
defaultMessage="Enable TLS"
/>
</EuiButton>
</EuiCallOut>
<EuiSpacer />
</Fragment>
)
)
);
};

View file

@ -0,0 +1,72 @@
/*
* 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 React, { Fragment } from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { SecurityEnabledCallOut } from './security_call_out';
import { EuiCallOut, EuiButton } from '@elastic/eui';
import { act } from 'react-dom/test-utils';
import { httpServiceMock } from '../../../../../../src/core/public/mocks';
const docLinks = { ELASTIC_WEBSITE_URL: 'elastic.co/', DOC_LINK_VERSION: 'current' };
const http = httpServiceMock.createStartContract();
describe('security call out', () => {
let useEffect: any;
const mockUseEffect = () => {
// make react execute useEffects despite shallow rendering
useEffect.mockImplementationOnce((f: Function) => f());
};
beforeEach(() => {
jest.resetAllMocks();
useEffect = jest.spyOn(React, 'useEffect');
mockUseEffect();
});
test('renders nothing while health is loading', async () => {
http.get.mockImplementationOnce(() => new Promise(() => {}));
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(<SecurityEnabledCallOut http={http} docLinks={docLinks} />);
});
expect(component?.is(Fragment)).toBeTruthy();
expect(component?.html()).toBe('');
});
test('renders nothing if keys are enabled', async () => {
http.get.mockResolvedValue({ isSufficientlySecure: true });
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(<SecurityEnabledCallOut http={http} docLinks={docLinks} />);
});
expect(component?.is(Fragment)).toBeTruthy();
expect(component?.html()).toBe('');
});
test('renders the callout if keys are disabled', async () => {
http.get.mockImplementationOnce(async () => ({ isSufficientlySecure: false }));
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(<SecurityEnabledCallOut http={http} docLinks={docLinks} />);
});
expect(component?.find(EuiCallOut).prop('title')).toMatchInlineSnapshot(
`"Enable Transport Layer Security"`
);
expect(component?.find(EuiButton).prop('href')).toMatchInlineSnapshot(
`"elastic.co/guide/en/kibana/current/configuring-tls.html"`
);
});
});

View file

@ -0,0 +1,75 @@
/*
* 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 React, { Fragment } from 'react';
import { Option, none, some, fold, filter } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { DocLinksStart, HttpSetup } from 'kibana/public';
import { AlertingFrameworkHealth } from '../../types';
import { health } from '../lib/alert_api';
interface Props {
docLinks: Pick<DocLinksStart, 'ELASTIC_WEBSITE_URL' | 'DOC_LINK_VERSION'>;
http: HttpSetup;
}
export const SecurityEnabledCallOut: React.FunctionComponent<Props> = ({ docLinks, http }) => {
const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
const [alertingHealth, setAlertingHealth] = React.useState<Option<AlertingFrameworkHealth>>(none);
React.useEffect(() => {
async function fetchSecurityConfigured() {
setAlertingHealth(some(await health({ http })));
}
fetchSecurityConfigured();
}, [http]);
return pipe(
alertingHealth,
filter(healthCheck => !healthCheck?.isSufficientlySecure),
fold(
() => <Fragment />,
() => (
<Fragment>
<EuiCallOut
title={i18n.translate(
'xpack.triggersActionsUI.components.securityCallOut.tlsDisabledTitle',
{
defaultMessage: 'Enable Transport Layer Security',
}
)}
color="primary"
iconType="iInCircle"
>
<p>
<FormattedMessage
id="xpack.triggersActionsUI.components.securityCallOut.tlsDisabledDescription"
defaultMessage="Alerting relies on API keys, which require TLS between Elasticsearch and Kibana."
/>
</p>
<EuiButton
href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`}
>
<FormattedMessage
id="xpack.triggersActionsUI.components.securityCallOut.enableTlsCta"
defaultMessage="Enable TLS"
/>
</EuiButton>
</EuiCallOut>
<EuiSpacer />
</Fragment>
)
)
);
};

View file

@ -5,7 +5,7 @@
*/
import React, { useContext, createContext } from 'react';
import { HttpSetup, IUiSettingsClient, ToastsApi } from 'kibana/public';
import { HttpSetup, IUiSettingsClient, ToastsApi, DocLinksStart } from 'kibana/public';
import { ChartsPluginSetup } from 'src/plugins/charts/public';
import { FieldFormatsRegistry } from 'src/plugins/data/common/field_formats';
import { TypeRegistry } from '../type_registry';
@ -23,6 +23,7 @@ export interface AlertsContextValue<MetaData = Record<string, any>> {
uiSettings?: IUiSettingsClient;
charts?: ChartsPluginSetup;
dataFieldsFormats?: Pick<FieldFormatsRegistry, 'register'>;
docLinks: DocLinksStart;
metadata?: MetaData;
}

View file

@ -29,6 +29,7 @@ import { hasShowActionsCapability, hasShowAlertsCapability } from './lib/capabil
import { ActionsConnectorsList } from './sections/actions_connectors_list/components/actions_connectors_list';
import { AlertsList } from './sections/alerts_list/components/alerts_list';
import { SecurityEnabledCallOut } from './components/security_call_out';
import { PLUGIN } from './constants/plugin';
interface MatchParams {
@ -41,7 +42,7 @@ export const TriggersActionsUIHome: React.FunctionComponent<RouteComponentProps<
},
history,
}) => {
const { chrome, capabilities, setBreadcrumbs } = useAppDependencies();
const { chrome, capabilities, setBreadcrumbs, docLinks, http } = useAppDependencies();
const canShowActions = hasShowActionsCapability(capabilities);
const canShowAlerts = hasShowAlertsCapability(capabilities);
@ -87,6 +88,7 @@ export const TriggersActionsUIHome: React.FunctionComponent<RouteComponentProps<
return (
<EuiPageBody>
<EuiPageContent>
<SecurityEnabledCallOut docLinks={docLinks} http={http} />
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle size="m">

View file

@ -24,6 +24,7 @@ import {
updateAlert,
muteAlertInstance,
unmuteAlertInstance,
health,
} from './alert_api';
import uuid from 'uuid';
@ -618,3 +619,17 @@ describe('unmuteAlerts', () => {
`);
});
});
describe('health', () => {
test('should call health API', async () => {
const result = await health({ http });
expect(result).toEqual(undefined);
expect(http.get.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/alert/_health",
],
]
`);
});
});

View file

@ -9,7 +9,7 @@ import * as t from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { pick } from 'lodash';
import { alertStateSchema } from '../../../../alerting/common';
import { alertStateSchema, AlertingFrameworkHealth } from '../../../../alerting/common';
import { BASE_ALERT_API_PATH } from '../constants';
import { Alert, AlertType, AlertWithoutId, AlertTaskState } from '../../types';
@ -214,3 +214,7 @@ export async function unmuteAlerts({
}): Promise<void> {
await Promise.all(ids.map(id => unmuteAlert({ id, http })));
}
export async function health({ http }: { http: HttpSetup }): Promise<AlertingFrameworkHealth> {
return await http.get(`${BASE_ALERT_API_PATH}/_health`);
}

View file

@ -17,6 +17,7 @@ import { alertTypeRegistryMock } from '../../alert_type_registry.mock';
import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { ReactWrapper } from 'enzyme';
import { AppContextProvider } from '../../app_context';
const actionTypeRegistry = actionTypeRegistryMock.create();
const alertTypeRegistry = alertTypeRegistryMock.create();
@ -49,7 +50,11 @@ describe('alert_add', () => {
charts: chartPluginMock.createStartContract(),
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: alertTypeRegistry as any,
docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' },
};
mockes.http.get.mockResolvedValue({ isSufficientlySecure: true });
const alertType = {
id: 'my-alert-type',
iconClass: 'test',
@ -83,22 +88,30 @@ describe('alert_add', () => {
actionTypeRegistry.has.mockReturnValue(true);
wrapper = mountWithIntl(
<AlertsContextProvider
value={{
reloadAlerts: () => {
return new Promise<void>(() => {});
},
http: deps.http,
actionTypeRegistry: deps.actionTypeRegistry,
alertTypeRegistry: deps.alertTypeRegistry,
toastNotifications: deps.toastNotifications,
uiSettings: deps.uiSettings,
metadata: { test: 'some value', fields: ['test'] },
}}
>
<AlertAdd consumer={'alerting'} addFlyoutVisible={true} setAddFlyoutVisibility={() => {}} />
</AlertsContextProvider>
<AppContextProvider appDeps={deps}>
<AlertsContextProvider
value={{
reloadAlerts: () => {
return new Promise<void>(() => {});
},
http: deps.http,
actionTypeRegistry: deps.actionTypeRegistry,
alertTypeRegistry: deps.alertTypeRegistry,
toastNotifications: deps.toastNotifications,
uiSettings: deps.uiSettings,
docLinks: deps.docLinks,
metadata: { test: 'some value', fields: ['test'] },
}}
>
<AlertAdd
consumer={'alerting'}
addFlyoutVisible={true}
setAddFlyoutVisibility={() => {}}
/>
</AlertsContextProvider>
</AppContextProvider>
);
// Wait for active space to resolve before requesting the component to update
await act(async () => {
await nextTick();
@ -108,12 +121,15 @@ describe('alert_add', () => {
it('renders alert add flyout', async () => {
await setup();
expect(wrapper.find('[data-test-subj="addAlertFlyoutTitle"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="saveAlertButton"]').exists()).toBeTruthy();
wrapper
.find('[data-test-subj="my-alert-type-SelectOption"]')
.first()
.simulate('click');
expect(wrapper.contains('Metadata: some value. Fields: test.')).toBeTruthy();
});
});

View file

@ -24,6 +24,7 @@ import { Alert, AlertAction, IErrorObject } from '../../../types';
import { AlertForm, validateBaseProperties } from './alert_form';
import { alertReducer } from './alert_reducer';
import { createAlert } from '../../lib/alert_api';
import { AlertActionSecurityCallOut } from '../../components/alert_action_security_call_out';
import { PLUGIN } from '../../constants/plugin';
interface AlertAddProps {
@ -65,6 +66,7 @@ export const AlertAdd = ({
toastNotifications,
alertTypeRegistry,
actionTypeRegistry,
docLinks,
} = useAlertsContext();
const closeFlyout = useCallback(() => {
@ -151,6 +153,16 @@ export const AlertAdd = ({
</h3>
</EuiTitle>
</EuiFlyoutHeader>
<AlertActionSecurityCallOut
docLinks={docLinks}
action={i18n.translate(
'xpack.triggersActionsUI.sections.alertAdd.securityCalloutAction',
{
defaultMessage: 'creation',
}
)}
http={http}
/>
<EuiFlyoutBody>
<AlertForm
alert={alert}

View file

@ -13,6 +13,7 @@ import { AlertsContextProvider } from '../../context/alerts_context';
import { alertTypeRegistryMock } from '../../alert_type_registry.mock';
import { ReactWrapper } from 'enzyme';
import { AlertEdit } from './alert_edit';
import { AppContextProvider } from '../../app_context';
const actionTypeRegistry = actionTypeRegistryMock.create();
const alertTypeRegistry = alertTypeRegistryMock.create();
@ -32,7 +33,11 @@ describe('alert_edit', () => {
uiSettings: mockedCoreSetup.uiSettings,
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: alertTypeRegistry as any,
docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' },
};
mockedCoreSetup.http.get.mockResolvedValue({ isSufficientlySecure: true });
const alertType = {
id: 'my-alert-type',
iconClass: 'test',
@ -102,24 +107,27 @@ describe('alert_edit', () => {
actionTypeRegistry.has.mockReturnValue(true);
wrapper = mountWithIntl(
<AlertsContextProvider
value={{
reloadAlerts: () => {
return new Promise<void>(() => {});
},
http: deps!.http,
actionTypeRegistry: deps!.actionTypeRegistry,
alertTypeRegistry: deps!.alertTypeRegistry,
toastNotifications: deps!.toastNotifications,
uiSettings: deps!.uiSettings,
}}
>
<AlertEdit
editFlyoutVisible={true}
setEditFlyoutVisibility={() => {}}
initialAlert={alert}
/>
</AlertsContextProvider>
<AppContextProvider appDeps={deps}>
<AlertsContextProvider
value={{
reloadAlerts: () => {
return new Promise<void>(() => {});
},
http: deps!.http,
actionTypeRegistry: deps!.actionTypeRegistry,
alertTypeRegistry: deps!.alertTypeRegistry,
toastNotifications: deps!.toastNotifications,
uiSettings: deps!.uiSettings,
docLinks: deps.docLinks,
}}
>
<AlertEdit
editFlyoutVisible={true}
setEditFlyoutVisibility={() => {}}
initialAlert={alert}
/>
</AlertsContextProvider>
</AppContextProvider>
);
// Wait for active space to resolve before requesting the component to update
await act(async () => {

View file

@ -26,6 +26,7 @@ import { Alert, AlertAction, IErrorObject } from '../../../types';
import { AlertForm, validateBaseProperties } from './alert_form';
import { alertReducer } from './alert_reducer';
import { updateAlert } from '../../lib/alert_api';
import { AlertActionSecurityCallOut } from '../../components/alert_action_security_call_out';
import { PLUGIN } from '../../constants/plugin';
interface AlertEditProps {
@ -49,6 +50,7 @@ export const AlertEdit = ({
toastNotifications,
alertTypeRegistry,
actionTypeRegistry,
docLinks,
} = useAlertsContext();
const closeFlyout = useCallback(() => {
@ -135,6 +137,16 @@ export const AlertEdit = ({
</h3>
</EuiTitle>
</EuiFlyoutHeader>
<AlertActionSecurityCallOut
docLinks={docLinks}
action={i18n.translate(
'xpack.triggersActionsUI.sections.alertEdit.securityCalloutAction',
{
defaultMessage: 'editing',
}
)}
http={http}
/>
<EuiFlyoutBody>
{hasActionsDisabled && (
<Fragment>

View file

@ -53,6 +53,7 @@ describe('alert_form', () => {
uiSettings: mockes.uiSettings,
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: alertTypeRegistry as any,
docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' },
};
alertTypeRegistry.list.mockReturnValue([alertType]);
alertTypeRegistry.has.mockReturnValue(true);
@ -80,6 +81,7 @@ describe('alert_form', () => {
return new Promise<void>(() => {});
},
http: deps!.http,
docLinks: deps.docLinks,
actionTypeRegistry: deps!.actionTypeRegistry,
alertTypeRegistry: deps!.alertTypeRegistry,
toastNotifications: deps!.toastNotifications,
@ -159,6 +161,7 @@ describe('alert_form', () => {
return new Promise<void>(() => {});
},
http: deps!.http,
docLinks: deps.docLinks,
actionTypeRegistry: deps!.actionTypeRegistry,
alertTypeRegistry: deps!.alertTypeRegistry,
toastNotifications: deps!.toastNotifications,

View file

@ -59,6 +59,7 @@ export const AlertsList: React.FunctionComponent = () => {
alertTypeRegistry,
actionTypeRegistry,
uiSettings,
docLinks,
charts,
dataPlugin,
} = useAppDependencies();
@ -480,6 +481,7 @@ export const AlertsList: React.FunctionComponent = () => {
alertTypeRegistry,
toastNotifications,
uiSettings,
docLinks,
charts,
dataFieldsFormats: dataPlugin.fieldFormats,
}}

View file

@ -6,7 +6,7 @@
import React from 'react';
import { Alert, AlertType, AlertTaskState } from '../../../../types';
import { Alert, AlertType, AlertTaskState, AlertingFrameworkHealth } from '../../../../types';
import { useAppDependencies } from '../../../app_context';
import {
deleteAlerts,
@ -23,6 +23,7 @@ import {
loadAlert,
loadAlertState,
loadAlertTypes,
health,
} from '../../../lib/alert_api';
export interface ComponentOpts {
@ -51,6 +52,7 @@ export interface ComponentOpts {
loadAlert: (id: Alert['id']) => Promise<Alert>;
loadAlertState: (id: Alert['id']) => Promise<AlertTaskState>;
loadAlertTypes: () => Promise<AlertType[]>;
getHealth: () => Promise<AlertingFrameworkHealth>;
}
export type PropsWithOptionalApiHandlers<T> = Omit<T, keyof ComponentOpts> & Partial<ComponentOpts>;
@ -115,6 +117,7 @@ export function withBulkAlertOperations<T>(
loadAlert={async (alertId: Alert['id']) => loadAlert({ http, alertId })}
loadAlertState={async (alertId: Alert['id']) => loadAlertState({ http, alertId })}
loadAlertTypes={async () => loadAlertTypes({ http })}
getHealth={async () => health({ http })}
/>
);
};

View file

@ -12,8 +12,9 @@ import {
AlertAction,
AlertTaskState,
RawAlertInstance,
AlertingFrameworkHealth,
} from '../../../plugins/alerting/common';
export { Alert, AlertAction, AlertTaskState, RawAlertInstance };
export { Alert, AlertAction, AlertTaskState, RawAlertInstance, AlertingFrameworkHealth };
export { ActionType };
export type ActionTypeIndex = Record<string, ActionType>;