[Synthetics] Validate API keys (#143867)

Co-authored-by: Dominique Clarke <dominique.clarke@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Fixes https://github.com/elastic/kibana/issues/142875
This commit is contained in:
Shahzad 2022-11-15 16:29:02 +01:00 committed by GitHub
parent b33acd0fe6
commit 75ce1e397a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 574 additions and 271 deletions

View file

@ -14,6 +14,7 @@ export const apiKeysMock = {
areAPIKeysEnabled: jest.fn(),
create: jest.fn(),
grantAsInternalUser: jest.fn(),
validate: jest.fn(),
invalidate: jest.fn(),
invalidateAsInternalUser: jest.fn(),
}),

View file

@ -18,6 +18,7 @@ import { ALL_SPACES_ID } from '../../../common/constants';
import type { SecurityLicense } from '../../../common/licensing';
import { licenseMock } from '../../../common/licensing/index.mock';
import { APIKeys } from './api_keys';
import { getFakeKibanaRequest } from './fake_kibana_request';
const encodeToBase64 = (str: string) => Buffer.from(str).toString('base64');
@ -407,6 +408,51 @@ describe('API Keys', () => {
});
});
describe('validate()', () => {
it('returns false when security feature is disabled', async () => {
mockLicense.isEnabled.mockReturnValue(false);
const result = await apiKeys.validate({
id: '123',
api_key: 'abc123',
});
expect(result).toEqual(false);
expect(mockClusterClient.asScoped).not.toHaveBeenCalled();
});
it('calls callCluster with proper parameters', async () => {
mockLicense.isEnabled.mockReturnValue(true);
const params = {
id: '123',
api_key: 'abc123',
};
const result = await apiKeys.validate(params);
expect(result).toEqual(true);
const fakeRequest = getFakeKibanaRequest(params);
const { id, uuid, ...restFake } = fakeRequest;
expect(mockClusterClient.asScoped).toHaveBeenCalledWith(expect.objectContaining(restFake));
expect(
mockClusterClient.asScoped().asCurrentUser.security.authenticate
).toHaveBeenCalledWith();
});
it('returns false if cannot authenticate with the API key', async () => {
mockLicense.isEnabled.mockReturnValue(true);
mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(new Error());
const params = { id: '123', api_key: 'abc123' };
await expect(apiKeys.validate(params)).resolves.toEqual(false);
const { id, uuid, ...restFake } = getFakeKibanaRequest(params);
expect(mockClusterClient.asScoped).toHaveBeenCalledWith(expect.objectContaining(restFake));
expect(
mockClusterClient.asScoped().asCurrentUser.security.authenticate
).toHaveBeenCalledTimes(1);
});
});
describe('invalidateAsInternalUser()', () => {
it('returns null when security feature is disabled', async () => {
mockLicense.isEnabled.mockReturnValue(false);

View file

@ -18,6 +18,7 @@ import {
BasicHTTPAuthorizationHeaderCredentials,
HTTPAuthorizationHeader,
} from '../http_authentication';
import { getFakeKibanaRequest } from './fake_kibana_request';
/**
* Represents the options to create an APIKey class instance that will be
@ -139,6 +140,21 @@ export interface InvalidateAPIKeyResult {
}>;
}
/**
* Represents the parameters for validating API Key credentials.
*/
export interface ValidateAPIKeyParams {
/**
* Unique id for this API key
*/
id: string;
/**
* Generated API Key (secret)
*/
api_key: string;
}
/**
* Class responsible for managing Elasticsearch API keys.
*/
@ -335,6 +351,30 @@ export class APIKeys {
return result;
}
/**
* Tries to validate an API key.
* @param apiKeyPrams ValidateAPIKeyParams.
*/
async validate(apiKeyPrams: ValidateAPIKeyParams): Promise<boolean> {
if (!this.license.isEnabled()) {
return false;
}
const fakeRequest = getFakeKibanaRequest(apiKeyPrams);
this.logger.debug(`Trying to validate an API key`);
try {
await this.clusterClient.asScoped(fakeRequest).asCurrentUser.security.authenticate();
this.logger.debug(`API key was validated successfully`);
return true;
} catch (e) {
this.logger.info(`Failed to validate API key: ${e.message}`);
}
return false;
}
private doesErrorIndicateAPIKeysAreDisabled(e: Record<string, any>) {
const disabledFeature = e.body?.error?.['disabled.feature'];
return disabledFeature === 'api_keys';

View file

@ -0,0 +1,32 @@
/*
* 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 { Request } from '@hapi/hapi';
import { CoreKibanaRequest } from '@kbn/core/server';
export function getFakeKibanaRequest(apiKey: { id: string; api_key: string }) {
const requestHeaders: Record<string, string> = {};
requestHeaders.authorization = `ApiKey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString(
'base64'
)}`;
return CoreKibanaRequest.from({
headers: requestHeaders,
path: '/',
route: { settings: {} },
url: {
href: '/',
},
raw: {
req: {
url: '/',
},
},
} as unknown as Request);
}

View file

@ -63,6 +63,7 @@ export interface InternalAuthenticationServiceStart extends AuthenticationServic
| 'areAPIKeysEnabled'
| 'create'
| 'invalidate'
| 'validate'
| 'grantAsInternalUser'
| 'invalidateAsInternalUser'
>;
@ -81,6 +82,7 @@ export interface AuthenticationServiceStart {
| 'areAPIKeysEnabled'
| 'create'
| 'invalidate'
| 'validate'
| 'grantAsInternalUser'
| 'invalidateAsInternalUser'
>;
@ -354,6 +356,7 @@ export class AuthenticationService {
create: apiKeys.create.bind(apiKeys),
grantAsInternalUser: apiKeys.grantAsInternalUser.bind(apiKeys),
invalidate: apiKeys.invalidate.bind(apiKeys),
validate: apiKeys.validate.bind(apiKeys),
invalidateAsInternalUser: apiKeys.invalidateAsInternalUser.bind(apiKeys),
},

View file

@ -141,6 +141,7 @@ describe('Security Plugin', () => {
"grantAsInternalUser": [Function],
"invalidate": [Function],
"invalidateAsInternalUser": [Function],
"validate": [Function],
},
"getCurrentUser": [Function],
},

View file

@ -40,6 +40,7 @@ export const MonitorManagementEnablementResultCodec = t.type({
canEnable: t.boolean,
canManageApiKeys: t.boolean,
areApiKeysEnabled: t.boolean,
isValidApiKey: t.boolean,
});
export type MonitorManagementEnablementResult = t.TypeOf<

View file

@ -31,6 +31,7 @@ export function useEnablement() {
canEnable: enablement?.canEnable,
isEnabled: enablement?.isEnabled,
},
invalidApiKeyError: enablement ? !Boolean(enablement?.isValidApiKey) : false,
error,
loading,
enableSynthetics: useCallback(() => dispatch(enableSynthetics()), [dispatch]),

View file

@ -58,6 +58,7 @@ export const syntheticsEnablementReducer = createReducer(initialState, (builder)
areApiKeysEnabled: state.enablement?.areApiKeysEnabled ?? false,
canManageApiKeys: state.enablement?.canManageApiKeys ?? false,
isEnabled: false,
isValidApiKey: true,
};
})
.addCase(disableSyntheticsFailure, (state, action) => {
@ -75,6 +76,7 @@ export const syntheticsEnablementReducer = createReducer(initialState, (builder)
canEnable: state.enablement?.canEnable ?? false,
areApiKeysEnabled: state.enablement?.areApiKeysEnabled ?? false,
canManageApiKeys: state.enablement?.canManageApiKeys ?? false,
isValidApiKey: state.enablement?.isValidApiKey ?? false,
isEnabled: true,
};
})

View file

@ -37,6 +37,7 @@ export function useEnablement() {
canEnable: enablement?.canEnable,
isEnabled: enablement?.isEnabled,
},
invalidApiKeyError: enablement ? !Boolean(enablement?.isValidApiKey) : false,
error,
loading,
totalMonitors: total,

View file

@ -0,0 +1,87 @@
/*
* 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 React from 'react';
import { EuiButton, EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useSelector } from 'react-redux';
import { monitorManagementListSelector } from '../../state/selectors';
import { useEnablement } from '../../components/monitor_management/hooks/use_enablement';
export const DisabledCallout = () => {
const { enablement, enableSynthetics } = useEnablement();
const { list: monitorList } = useSelector(monitorManagementListSelector);
const showDisableCallout = !enablement.isEnabled && monitorList.total && monitorList.total > 0;
if (!showDisableCallout) {
return null;
}
return (
<>
<EuiCallOut title={CALLOUT_MANAGEMENT_DISABLED} color="warning" iconType="help">
<p>{CALLOUT_MANAGEMENT_DESCRIPTION}</p>
{enablement.canEnable ? (
<EuiButton
fill
color="primary"
onClick={() => {
enableSynthetics();
}}
>
{SYNTHETICS_ENABLE_LABEL}
</EuiButton>
) : (
<p>
{CALLOUT_MANAGEMENT_CONTACT_ADMIN}{' '}
<EuiLink href="#" target="_blank">
{LEARN_MORE_LABEL}
</EuiLink>
</p>
)}
</EuiCallOut>
<EuiSpacer size="s" />
</>
);
};
const LEARN_MORE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.manageMonitorLoadingLabel.disabledCallout.learnMore',
{
defaultMessage: 'Learn more',
}
);
const CALLOUT_MANAGEMENT_DISABLED = i18n.translate(
'xpack.synthetics.monitorManagement.callout.disabled',
{
defaultMessage: 'Monitor Management is disabled',
}
);
const CALLOUT_MANAGEMENT_CONTACT_ADMIN = i18n.translate(
'xpack.synthetics.monitorManagement.disabledCallout.adminContact',
{
defaultMessage: 'Contact your administrator to enable Monitor Management.',
}
);
const CALLOUT_MANAGEMENT_DESCRIPTION = i18n.translate(
'xpack.synthetics.monitorManagement.disabledCallout.description.disabled',
{
defaultMessage:
'Monitor Management is currently disabled and your existing monitors are paused. You can enable Monitor Management to run your monitors.',
}
);
const SYNTHETICS_ENABLE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.syntheticsEnableLabel.management',
{
defaultMessage: 'Enable Monitor Management',
}
);

View file

@ -0,0 +1,80 @@
/*
* 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 React from 'react';
import { EuiButton, EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useEnablement } from '../../components/monitor_management/hooks/use_enablement';
export const InvalidApiKeyCalloutCallout = () => {
const { enablement, enableSynthetics, invalidApiKeyError } = useEnablement();
if (!invalidApiKeyError || !enablement.isEnabled) {
return null;
}
return (
<>
<EuiCallOut title={API_KEY_MISSING} color="warning" iconType="help">
<p>{CALLOUT_MANAGEMENT_DESCRIPTION}</p>
{enablement.canEnable ? (
<EuiButton
fill
color="primary"
onClick={() => {
enableSynthetics();
}}
>
{SYNTHETICS_ENABLE_LABEL}
</EuiButton>
) : (
<p>
{CALLOUT_MANAGEMENT_CONTACT_ADMIN}{' '}
<EuiLink href="#" target="_blank">
{LEARN_MORE_LABEL}
</EuiLink>
</p>
)}
</EuiCallOut>
<EuiSpacer size="s" />
</>
);
};
const LEARN_MORE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.manageMonitorLoadingLabel.callout.invalidKey',
{
defaultMessage: 'Learn more',
}
);
const API_KEY_MISSING = i18n.translate('xpack.synthetics.monitorManagement.callout.apiKeyMissing', {
defaultMessage: 'Monitor Management is currently disabled because of missing API key',
});
const CALLOUT_MANAGEMENT_CONTACT_ADMIN = i18n.translate(
'xpack.synthetics.monitorManagement.callout.disabledCallout.invalidKey',
{
defaultMessage: 'Contact your administrator to enable Monitor Management.',
}
);
const CALLOUT_MANAGEMENT_DESCRIPTION = i18n.translate(
'xpack.synthetics.monitorManagement.callout.description.invalidKey',
{
defaultMessage:
`Monitor Management is currently disabled. To run your monitors in one of Elastic's global managed testing locations,` +
'you need to re-enable monitor management.',
}
);
const SYNTHETICS_ENABLE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.syntheticsEnableLabel.invalidKey',
{
defaultMessage: 'Enable monitor management',
}
);

View file

@ -8,8 +8,9 @@
import React, { useEffect, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { useSelector } from 'react-redux';
import { EuiCallOut, EuiButton, EuiSpacer, EuiLink } from '@elastic/eui';
import { useTrackPageview } from '@kbn/observability-plugin/public';
import { InvalidApiKeyCalloutCallout } from './invalid_api_key_callout';
import { DisabledCallout } from './disabled_callout';
import { ManageLocationsPortal } from '../../components/monitor_management/manage_locations/manage_locations';
import { monitorManagementListSelector } from '../../state/selectors';
import { useMonitorManagementBreadcrumbs } from './use_monitor_management_breadcrumbs';
@ -27,12 +28,7 @@ export const MonitorManagementPage: React.FC = () => {
useMonitorManagementBreadcrumbs();
const [shouldFocusEnablementButton, setShouldFocusEnablementButton] = useState(false);
const {
error: enablementError,
enablement,
loading: enablementLoading,
enableSynthetics,
} = useEnablement();
const { error: enablementError, enablement, loading: enablementLoading } = useEnablement();
const { loading: locationsLoading } = useLocations();
const { list: monitorList } = useSelector(monitorManagementListSelector);
const { isEnabled } = enablement;
@ -61,32 +57,8 @@ export const MonitorManagementPage: React.FC = () => {
errorTitle={ERROR_HEADING_LABEL}
errorBody={ERROR_HEADING_BODY}
>
{!isEnabled && monitorList.total && monitorList.total > 0 ? (
<>
<EuiCallOut title={CALLOUT_MANAGEMENT_DISABLED} color="warning" iconType="help">
<p>{CALLOUT_MANAGEMENT_DESCRIPTION}</p>
{enablement.canEnable ? (
<EuiButton
fill
color="primary"
onClick={() => {
enableSynthetics();
}}
>
{SYNTHETICS_ENABLE_LABEL}
</EuiButton>
) : (
<p>
{CALLOUT_MANAGEMENT_CONTACT_ADMIN}{' '}
<EuiLink href="#" target="_blank">
{LEARN_MORE_LABEL}
</EuiLink>
</p>
)}
</EuiCallOut>
<EuiSpacer size="s" />
</>
) : null}
<InvalidApiKeyCalloutCallout />
<DisabledCallout />
<MonitorListContainer
isEnabled={isEnabled}
pageState={pageState}
@ -106,45 +78,9 @@ const LOADING_LABEL = i18n.translate(
}
);
const LEARN_MORE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.manageMonitorLoadingLabel.callout.learnMore',
{
defaultMessage: 'Learn more.',
}
);
const CALLOUT_MANAGEMENT_DISABLED = i18n.translate(
'xpack.synthetics.monitorManagement.callout.disabled',
{
defaultMessage: 'Monitor Management is disabled',
}
);
const CALLOUT_MANAGEMENT_CONTACT_ADMIN = i18n.translate(
'xpack.synthetics.monitorManagement.callout.disabled.adminContact',
{
defaultMessage: 'Please contact your administrator to enable Monitor Management.',
}
);
const CALLOUT_MANAGEMENT_DESCRIPTION = i18n.translate(
'xpack.synthetics.monitorManagement.callout.description.disabled',
{
defaultMessage:
'Monitor Management is currently disabled. To run your monitors on Elastic managed Synthetics service, enable Monitor Management. Your existing monitors are paused.',
}
);
const ERROR_HEADING_BODY = i18n.translate(
'xpack.synthetics.monitorManagement.editMonitorError.description',
{
defaultMessage: 'Monitor Management settings could not be loaded. Please contact Support.',
}
);
const SYNTHETICS_ENABLE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.syntheticsEnableLabel.management',
{
defaultMessage: 'Enable Monitor Management',
}
);

View file

@ -114,7 +114,7 @@ export const fetchDisableSynthetics = async (): Promise<void> => {
return await apiService.delete(API_URLS.SYNTHETICS_ENABLEMENT);
};
export const fetchEnableSynthetics = async (): Promise<void> => {
export const fetchEnableSynthetics = async (): Promise<MonitorManagementEnablementResult> => {
return await apiService.post(API_URLS.SYNTHETICS_ENABLEMENT);
};

View file

@ -217,6 +217,7 @@ export const monitorManagementListReducer = createReducer(initialState, (builder
canEnable: state.enablement?.canEnable || false,
areApiKeysEnabled: state.enablement?.areApiKeysEnabled || false,
isEnabled: false,
isValidApiKey: state.enablement?.isValidApiKey || false,
},
}))
.addCase(
@ -240,23 +241,21 @@ export const monitorManagementListReducer = createReducer(initialState, (builder
enablement: true,
},
}))
.addCase(enableSyntheticsSuccess, (state: WritableDraft<MonitorManagementList>) => ({
...state,
loading: {
...state.loading,
enablement: false,
},
error: {
...state.error,
enablement: null,
},
enablement: {
canManageApiKeys: state.enablement?.canManageApiKeys || false,
canEnable: state.enablement?.canEnable || false,
areApiKeysEnabled: state.enablement?.areApiKeysEnabled || false,
isEnabled: true,
},
}))
.addCase(
enableSyntheticsSuccess,
(state: WritableDraft<MonitorManagementList>, action: PayloadAction<any>) => ({
...state,
loading: {
...state.loading,
enablement: false,
},
error: {
...state.error,
enablement: null,
},
enablement: action.payload,
})
)
.addCase(
enableSyntheticsFailure,
(state: WritableDraft<MonitorManagementList>, action: PayloadAction<Error>) => ({

View file

@ -11,6 +11,7 @@ import type {
IScopedClusterClient,
Logger,
IBasePath,
CoreStart,
} from '@kbn/core/server';
import type { TelemetryPluginSetup, TelemetryPluginStart } from '@kbn/telemetry-plugin/server';
import { ObservabilityPluginSetup } from '@kbn/observability-plugin/server';
@ -65,6 +66,7 @@ export interface UptimeServerSetup {
uptimeEsClient: UptimeEsClient;
basePath: IBasePath;
isDev?: boolean;
coreStart: CoreStart;
}
export interface UptimeCorePluginsSetup {

View file

@ -28,7 +28,6 @@ import { getJourneyScreenshotBlocks } from './get_journey_screenshot_blocks';
import { getSyntheticsMonitor } from './get_monitor';
import {
getSyntheticsEnablement,
deleteServiceApiKey,
generateAndSaveServiceAPIKey,
getAPIKeyForSyntheticsService,
} from '../../../synthetics_service/get_api_key';
@ -57,7 +56,6 @@ export const uptimeRequests = {
getNetworkEvents,
getSyntheticsEnablement,
getAPIKeyForSyntheticsService,
deleteServiceApiKey,
generateAndSaveServiceAPIKey,
};

View file

@ -11,8 +11,8 @@ import {
SavedObjectsErrorHelpers,
SavedObjectsType,
} from '@kbn/core/server';
import { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server';
import { SyntheticsServiceApiKey } from '../../../../common/runtime_types/synthetics_service_api_key';
import { UptimeServerSetup } from '../adapters';
export const syntheticsApiKeyID = 'ba997842-b0cf-4429-aa9d-578d9bf0d391';
export const syntheticsApiKeyObjectType = 'uptime-synthetics-api-key';
@ -49,9 +49,17 @@ export const syntheticsServiceApiKey: SavedObjectsType = {
},
};
export const getSyntheticsServiceAPIKey = async (client: EncryptedSavedObjectsClient) => {
const getEncryptedSOClient = (server: UptimeServerSetup) => {
const encryptedClient = server.encryptedSavedObjects.getClient({
includedHiddenTypes: [syntheticsServiceApiKey.name],
});
return encryptedClient;
};
const getSyntheticsServiceAPIKey = async (server: UptimeServerSetup) => {
try {
const obj = await client.getDecryptedAsInternalUser<SyntheticsServiceApiKey>(
const soClient = getEncryptedSOClient(server);
const obj = await soClient.getDecryptedAsInternalUser<SyntheticsServiceApiKey>(
syntheticsServiceApiKey.name,
syntheticsApiKeyID
);
@ -64,20 +72,26 @@ export const getSyntheticsServiceAPIKey = async (client: EncryptedSavedObjectsCl
}
};
export const setSyntheticsServiceApiKey = async (
client: SavedObjectsClientContract,
const setSyntheticsServiceApiKey = async (
soClient: SavedObjectsClientContract,
apiKey: SyntheticsServiceApiKey
) => {
await client.create(syntheticsServiceApiKey.name, apiKey, {
await soClient.create(syntheticsServiceApiKey.name, apiKey, {
id: syntheticsApiKeyID,
overwrite: true,
});
};
export const deleteSyntheticsServiceApiKey = async (client: SavedObjectsClientContract) => {
const deleteSyntheticsServiceApiKey = async (soClient: SavedObjectsClientContract) => {
try {
return await client.delete(syntheticsServiceApiKey.name, syntheticsApiKeyID);
return await soClient.delete(syntheticsServiceApiKey.name, syntheticsApiKeyID);
} catch (e) {
throw e;
}
};
export const syntheticsServiceAPIKeySavedObject = {
get: getSyntheticsServiceAPIKey,
set: setSyntheticsServiceApiKey,
delete: deleteSyntheticsServiceApiKey,
};

View file

@ -121,6 +121,7 @@ export class Plugin implements PluginType {
);
if (this.server) {
this.server.coreStart = coreStart;
this.server.security = pluginsStart.security;
this.server.fleet = pluginsStart.fleet;
this.server.encryptedSavedObjects = pluginsStart.encryptedSavedObjects;

View file

@ -13,11 +13,8 @@ export const getAPIKeySyntheticsRoute: SyntheticsRestApiRouteFactory = (libs) =>
path: API_URLS.SYNTHETICS_APIKEY,
validate: {},
handler: async ({ request, server }): Promise<any> => {
const { security } = server;
const apiKey = await generateAPIKey({
request,
security,
server,
uptimePrivileges: true,
});

View file

@ -4,22 +4,25 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { syntheticsServiceAPIKeySavedObject } from '../../legacy_uptime/lib/saved_objects/service_api_key';
import {
SyntheticsRestApiRouteFactory,
UMRestApiRouteFactory,
} from '../../legacy_uptime/routes/types';
import { API_URLS } from '../../../common/constants';
import { SyntheticsForbiddenError } from '../../synthetics_service/get_api_key';
import {
generateAndSaveServiceAPIKey,
SyntheticsForbiddenError,
} from '../../synthetics_service/get_api_key';
export const getSyntheticsEnablementRoute: UMRestApiRouteFactory = (libs) => ({
method: 'GET',
path: API_URLS.SYNTHETICS_ENABLEMENT,
validate: {},
handler: async ({ request, response, server }): Promise<any> => {
handler: async ({ response, server }): Promise<any> => {
try {
return response.ok({
body: await libs.requests.getSyntheticsEnablement({
request,
server,
}),
});
@ -38,25 +41,21 @@ export const disableSyntheticsRoute: SyntheticsRestApiRouteFactory = (libs) => (
response,
request,
server,
savedObjectsClient,
syntheticsMonitorClient,
savedObjectsClient,
}): Promise<any> => {
const { security } = server;
const { syntheticsService } = syntheticsMonitorClient;
try {
const { canEnable } = await libs.requests.getSyntheticsEnablement({ request, server });
const { canEnable } = await libs.requests.getSyntheticsEnablement({ server });
if (!canEnable) {
return response.forbidden();
}
await syntheticsService.deleteAllConfigs();
const apiKey = await libs.requests.getAPIKeyForSyntheticsService({
const { apiKey } = await libs.requests.getAPIKeyForSyntheticsService({
server,
});
await libs.requests.deleteServiceApiKey({
request,
server,
savedObjectsClient,
});
await syntheticsServiceAPIKeySavedObject.delete(savedObjectsClient);
await security.authc.apiKeys?.invalidate(request, { ids: [apiKey?.id || ''] });
return response.ok({});
} catch (e) {
@ -71,15 +70,18 @@ export const enableSyntheticsRoute: UMRestApiRouteFactory = (libs) => ({
path: API_URLS.SYNTHETICS_ENABLEMENT,
validate: {},
handler: async ({ request, response, server }): Promise<any> => {
const { authSavedObjectsClient, logger, security } = server;
const { authSavedObjectsClient, logger } = server;
try {
await libs.requests.generateAndSaveServiceAPIKey({
await generateAndSaveServiceAPIKey({
request,
authSavedObjectsClient,
security,
server,
});
return response.ok({});
return response.ok({
body: await libs.requests.getSyntheticsEnablement({
server,
}),
});
} catch (e) {
logger.error(e);
if (e instanceof SyntheticsForbiddenError) {

View file

@ -0,0 +1,24 @@
/*
* 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 { UptimeServerSetup } from '../../legacy_uptime/lib/adapters';
import { getFakeKibanaRequest } from '../utils/fake_kibana_request';
import { serviceApiKeyPrivileges } from '../get_api_key';
export const checkHasPrivileges = async (
server: UptimeServerSetup,
apiKey: { id: string; apiKey: string }
) => {
return await server.coreStart.elasticsearch.client
.asScoped(getFakeKibanaRequest({ id: apiKey.id, api_key: apiKey.apiKey }))
.asCurrentUser.security.hasPrivileges({
body: {
index: serviceApiKeyPrivileges.indices,
cluster: serviceApiKeyPrivileges.cluster,
},
});
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { getAPIKeyForSyntheticsService } from './get_api_key';
import { getAPIKeyForSyntheticsService, syntheticsIndex } from './get_api_key';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { coreMock } from '@kbn/core/server/mocks';
@ -13,6 +13,9 @@ import { syntheticsServiceApiKey } from '../legacy_uptime/lib/saved_objects/serv
import { KibanaRequest } from '@kbn/core/server';
import { UptimeServerSetup } from '../legacy_uptime/lib/adapters';
import { getUptimeESMockClient } from '../legacy_uptime/lib/requests/test_helpers';
import { loggerMock } from '@kbn/logging-mocks';
import * as authUtils from './authentication/check_has_privilege';
describe('getAPIKeyTest', function () {
const core = coreMock.createStart();
@ -20,7 +23,20 @@ describe('getAPIKeyTest', function () {
const encryptedSavedObjects = encryptedSavedObjectsMock.createStart();
const request = {} as KibanaRequest;
const logger = loggerMock.create();
jest.spyOn(authUtils, 'checkHasPrivileges').mockResolvedValue({
index: {
[syntheticsIndex]: {
auto_configure: true,
create_doc: true,
view_index_metadata: true,
},
},
} as any);
const server = {
logger,
security,
encryptedSavedObjects,
savedObjectsClient: core.savedObjects.getScopedClient(request),
@ -28,6 +44,7 @@ describe('getAPIKeyTest', function () {
} as unknown as UptimeServerSetup;
security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValue(true);
security.authc.apiKeys.validate = jest.fn().mockReturnValue(true);
security.authc.apiKeys.create = jest.fn().mockReturnValue({
id: 'test',
name: 'service-api-key',
@ -47,7 +64,10 @@ describe('getAPIKeyTest', function () {
server,
});
expect(apiKey).toEqual({ apiKey: 'qwerty', id: 'test', name: 'service-api-key' });
expect(apiKey).toEqual({
apiKey: { apiKey: 'qwerty', id: 'test', name: 'service-api-key' },
isValid: true,
});
expect(encryptedSavedObjects.getClient).toHaveBeenCalledTimes(1);
expect(getObject).toHaveBeenCalledTimes(1);

View file

@ -10,22 +10,19 @@ import type {
} from '@elastic/elasticsearch/lib/api/types';
import { KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server';
import { SecurityPluginStart } from '@kbn/security-plugin/server';
import { ALL_SPACES_ID } from '@kbn/security-plugin/common/constants';
import {
deleteSyntheticsServiceApiKey,
getSyntheticsServiceAPIKey,
setSyntheticsServiceApiKey,
syntheticsServiceApiKey,
} from '../legacy_uptime/lib/saved_objects/service_api_key';
import { syntheticsServiceAPIKeySavedObject } from '../legacy_uptime/lib/saved_objects/service_api_key';
import { SyntheticsServiceApiKey } from '../../common/runtime_types/synthetics_service_api_key';
import { UptimeServerSetup } from '../legacy_uptime/lib/adapters';
import { checkHasPrivileges } from './authentication/check_has_privilege';
export const syntheticsIndex = 'synthetics-*';
export const serviceApiKeyPrivileges = {
cluster: ['monitor', 'read_ilm', 'read_pipeline'] as SecurityClusterPrivilege[],
indices: [
{
names: ['synthetics-*'],
names: [syntheticsIndex],
privileges: [
'view_index_metadata',
'create_doc',
@ -41,47 +38,61 @@ export const getAPIKeyForSyntheticsService = async ({
server,
}: {
server: UptimeServerSetup;
}): Promise<SyntheticsServiceApiKey | undefined> => {
const { encryptedSavedObjects } = server;
const encryptedClient = encryptedSavedObjects.getClient({
includedHiddenTypes: [syntheticsServiceApiKey.name],
});
}): Promise<{ apiKey?: SyntheticsServiceApiKey; isValid: boolean }> => {
try {
const apiKey = await getSyntheticsServiceAPIKey(encryptedClient);
const apiKey = await syntheticsServiceAPIKeySavedObject.get(server);
if (apiKey) {
return apiKey;
const isValid = await server.security.authc.apiKeys.validate({
id: apiKey.id,
api_key: apiKey.apiKey,
});
if (isValid) {
const { index } = await checkHasPrivileges(server, apiKey);
const indexPermissions = index[syntheticsIndex];
const hasPermissions =
indexPermissions.auto_configure &&
indexPermissions.create_doc &&
indexPermissions.view_index_metadata;
if (!hasPermissions) {
return { isValid: false, apiKey };
}
} else {
server.logger.info('Synthetics api is no longer valid');
}
return { apiKey, isValid };
}
} catch (err) {
// TODO: figure out how to handle decryption errors
server.logger.error(err);
}
return { isValid: false };
};
export const generateAPIKey = async ({
server,
security,
request,
uptimePrivileges = false,
}: {
server: UptimeServerSetup;
request?: KibanaRequest;
security: SecurityPluginStart;
request: KibanaRequest;
uptimePrivileges?: boolean;
}) => {
const { security } = server;
const isApiKeysEnabled = await security.authc.apiKeys?.areAPIKeysEnabled();
if (!isApiKeysEnabled) {
throw new Error('Please enable API keys in kibana to use synthetics service.');
}
if (!request) {
throw new Error('User authorization is needed for api key generation');
}
if (uptimePrivileges) {
return security.authc.apiKeys?.create(request, {
name: 'uptime-api-key',
name: 'synthetics-api-key (required for project monitors)',
kibana_role_descriptors: {
uptime_save: {
elasticsearch: {},
@ -105,13 +116,13 @@ export const generateAPIKey = async ({
});
}
const { canEnable } = await getSyntheticsEnablement({ request, server });
const { canEnable } = await hasEnablePermissions(server);
if (!canEnable) {
throw new SyntheticsForbiddenError();
}
return security.authc.apiKeys?.create(request, {
name: 'synthetics-api-key',
name: 'synthetics-api-key (required for monitor management)',
role_descriptors: {
synthetics_writer: serviceApiKeyPrivileges,
},
@ -124,68 +135,59 @@ export const generateAPIKey = async ({
export const generateAndSaveServiceAPIKey = async ({
server,
security,
request,
authSavedObjectsClient,
}: {
server: UptimeServerSetup;
request?: KibanaRequest;
security: SecurityPluginStart;
request: KibanaRequest;
// authSavedObject is needed for write operations
authSavedObjectsClient?: SavedObjectsClientContract;
}) => {
const apiKeyResult = await generateAPIKey({ server, request, security });
const apiKeyResult = await generateAPIKey({ server, request });
if (apiKeyResult) {
const { id, name, api_key: apiKey } = apiKeyResult;
const apiKeyObject = { id, name, apiKey };
if (authSavedObjectsClient) {
// discard decoded key and rest of the keys
await setSyntheticsServiceApiKey(authSavedObjectsClient, apiKeyObject);
await syntheticsServiceAPIKeySavedObject.set(authSavedObjectsClient, apiKeyObject);
}
return apiKeyObject;
}
};
export const deleteServiceApiKey = async ({
request,
server,
savedObjectsClient,
}: {
server: UptimeServerSetup;
request?: KibanaRequest;
savedObjectsClient: SavedObjectsClientContract;
}) => {
await deleteSyntheticsServiceApiKey(savedObjectsClient);
};
export const getSyntheticsEnablement = async ({
request,
server: { uptimeEsClient, security, encryptedSavedObjects },
}: {
server: UptimeServerSetup;
request?: KibanaRequest;
}) => {
const encryptedClient = encryptedSavedObjects.getClient({
includedHiddenTypes: [syntheticsServiceApiKey.name],
});
export const getSyntheticsEnablement = async ({ server }: { server: UptimeServerSetup }) => {
const { security } = server;
const [apiKey, hasPrivileges, areApiKeysEnabled] = await Promise.all([
getSyntheticsServiceAPIKey(encryptedClient),
uptimeEsClient.baseESClient.security.hasPrivileges({
body: {
cluster: [
'manage_security',
'manage_api_key',
'manage_own_api_key',
...serviceApiKeyPrivileges.cluster,
],
index: serviceApiKeyPrivileges.indices,
},
}),
getAPIKeyForSyntheticsService({ server }),
hasEnablePermissions(server),
security.authc.apiKeys.areAPIKeysEnabled(),
]);
const { canEnable, canManageApiKeys } = hasPrivileges;
return {
canEnable,
canManageApiKeys,
isEnabled: Boolean(apiKey?.apiKey),
isValidApiKey: apiKey?.isValid,
areApiKeysEnabled,
};
};
const hasEnablePermissions = async ({ uptimeEsClient }: UptimeServerSetup) => {
const hasPrivileges = await uptimeEsClient.baseESClient.security.hasPrivileges({
body: {
cluster: [
'manage_security',
'manage_api_key',
'manage_own_api_key',
...serviceApiKeyPrivileges.cluster,
],
index: serviceApiKeyPrivileges.indices,
},
});
const { cluster } = hasPrivileges;
const {
manage_security: manageSecurity,
@ -203,10 +205,8 @@ export const getSyntheticsEnablement = async ({
);
return {
canEnable: canManageApiKeys && hasClusterPermissions && hasIndexPermissions,
canManageApiKeys,
isEnabled: Boolean(apiKey),
areApiKeysEnabled,
canEnable: canManageApiKeys && hasClusterPermissions && hasIndexPermissions,
};
};

View file

@ -189,6 +189,9 @@ export class ServiceAPIClient {
}),
catchError((err: AxiosError<{ reason: string; status: number }>) => {
pushErrors.push({ locationId: id, error: err.response?.data! });
const reason = err.response?.data?.reason ?? '';
err.message = `Failed to call service location ${url} with method ${method} with ${allMonitors.length} monitors: ${err.message}, ${reason}`;
this.logger.error(err);
sendErrorTelemetryEvents(this.logger, this.server.telemetry, {
reason: err.response?.data?.reason,
@ -199,9 +202,6 @@ export class ServiceAPIClient {
url,
stackVersion: this.server.stackVersion,
});
if (err.response?.data?.reason) {
this.logger.error(err.response?.data?.reason);
}
// we don't want to throw an unhandled exception here
return of(true);
})

View file

@ -7,35 +7,33 @@
/* eslint-disable max-classes-per-file */
import { SavedObject } from '@kbn/core/server';
import { Logger } from '@kbn/core/server';
import { Logger, SavedObject } from '@kbn/core/server';
import {
ConcreteTaskInstance,
TaskInstance,
TaskManagerSetupContract,
TaskManagerStartContract,
TaskInstance,
} from '@kbn/task-manager-plugin/server';
import { Subject } from 'rxjs';
import { sendErrorTelemetryEvents } from '../routes/telemetry/monitor_upgrade_sender';
import { UptimeServerSetup } from '../legacy_uptime/lib/adapters';
import { installSyntheticsIndexTemplates } from '../routes/synthetics_service/install_index_templates';
import { SyntheticsServiceApiKey } from '../../common/runtime_types/synthetics_service_api_key';
import { getAPIKeyForSyntheticsService } from './get_api_key';
import { syntheticsMonitorType } from '../legacy_uptime/lib/saved_objects/synthetics_monitor';
import { getEsHosts } from './get_es_hosts';
import { ServiceConfig } from '../../common/config';
import { ServiceAPIClient } from './service_api_client';
import { formatMonitorConfig, formatHeartbeatRequest } from './formatters/format_configs';
import { formatHeartbeatRequest, formatMonitorConfig } from './formatters/format_configs';
import {
ConfigKey,
HeartbeatConfig,
MonitorFields,
ServiceLocationErrors,
ServiceLocations,
SyntheticsMonitor,
ThrottlingOptions,
SyntheticsMonitorWithId,
ServiceLocationErrors,
SyntheticsMonitorWithSecrets,
HeartbeatConfig,
ThrottlingOptions,
} from '../../common/runtime_types';
import { getServiceLocations } from './get_service_locations';
@ -54,8 +52,6 @@ export class SyntheticsService {
private readonly config: ServiceConfig;
private readonly esHosts: string[];
private apiKey: SyntheticsServiceApiKey | undefined;
public locations: ServiceLocations;
public throttling: ThrottlingOptions | undefined;
@ -67,6 +63,8 @@ export class SyntheticsService {
public syncErrors?: ServiceLocationErrors | null = [];
public invalidApiKeyError?: boolean;
constructor(server: UptimeServerSetup) {
this.logger = server.logger;
this.server = server;
@ -178,7 +176,7 @@ export class SyntheticsService {
status: e.status,
stackVersion: service.server.stackVersion,
});
throw e;
service.logger.error(e);
}
return { state };
@ -235,17 +233,19 @@ export class SyntheticsService {
}
async getApiKey() {
try {
this.apiKey = await getAPIKeyForSyntheticsService({ server: this.server });
} catch (err) {
this.logger.error(err);
throw err;
const { apiKey, isValid } = await getAPIKeyForSyntheticsService({ server: this.server });
if (!isValid) {
throw new Error(
'API key is not valid. Cannot push monitor configuration to synthetics public testing locations'
);
}
return this.apiKey;
return apiKey;
}
async getOutput(apiKey: SyntheticsServiceApiKey) {
async getOutput() {
const apiKey = await this.getApiKey();
return {
hosts: this.esHosts,
api_key: `${apiKey?.id}:${apiKey?.apiKey}`,
@ -255,21 +255,15 @@ export class SyntheticsService {
async addConfig(config: HeartbeatConfig | HeartbeatConfig[]) {
const monitors = this.formatConfigs(Array.isArray(config) ? config : [config]);
this.apiKey = await this.getApiKey();
if (!this.apiKey) {
return null;
}
const data = {
monitors,
output: await this.getOutput(this.apiKey),
};
const output = await this.getOutput();
this.logger.debug(`1 monitor will be pushed to synthetics service.`);
try {
this.syncErrors = await this.apiClient.post(data);
this.syncErrors = await this.apiClient.post({
monitors,
output,
});
return this.syncErrors;
} catch (e) {
this.logger.error(e);
@ -282,15 +276,10 @@ export class SyntheticsService {
Array.isArray(monitorConfig) ? monitorConfig : [monitorConfig]
);
this.apiKey = await this.getApiKey();
if (!this.apiKey) {
return null;
}
const output = await this.getOutput();
const data = {
monitors,
output: await this.getOutput(this.apiKey),
output,
isEdit: true,
};
@ -308,31 +297,32 @@ export class SyntheticsService {
const subject = new Subject<SyntheticsMonitorWithId[]>();
subject.subscribe(async (monitorConfigs) => {
const monitors = this.formatConfigs(monitorConfigs);
if (monitors.length === 0) {
this.logger.debug('No monitor found which can be pushed to service.');
return null;
}
this.apiKey = await this.getApiKey();
if (!this.apiKey) {
return null;
}
const data = {
monitors,
output: await this.getOutput(this.apiKey),
};
this.logger.debug(`${monitors.length} monitors will be pushed to synthetics service.`);
try {
service.syncErrors = await this.apiClient.put(data);
const monitors = this.formatConfigs(monitorConfigs);
if (monitors.length === 0) {
this.logger.debug('No monitor found which can be pushed to service.');
return null;
}
const output = await this.getOutput();
this.logger.debug(`${monitors.length} monitors will be pushed to synthetics service.`);
service.syncErrors = await this.apiClient.put({
monitors,
output,
});
} catch (e) {
sendErrorTelemetryEvents(service.logger, service.server.telemetry, {
reason: 'Failed to push configs to service',
message: e?.message,
type: 'pushConfigsError',
code: e?.code,
status: e.status,
stackVersion: service.server.stackVersion,
});
this.logger.error(e);
throw e;
}
});
@ -345,19 +335,13 @@ export class SyntheticsService {
return;
}
this.apiKey = await this.getApiKey();
if (!this.apiKey) {
return null;
}
const data = {
monitors,
output: await this.getOutput(this.apiKey),
};
const output = await this.getOutput();
try {
return await this.apiClient.runOnce(data);
return await this.apiClient.runOnce({
monitors,
output,
});
} catch (e) {
this.logger.error(e);
throw e;
@ -365,21 +349,13 @@ export class SyntheticsService {
}
async deleteConfigs(configs: SyntheticsMonitorWithId[]) {
this.apiKey = await this.getApiKey();
if (!this.apiKey) {
return null;
}
const output = await this.getOutput();
const data = {
output,
monitors: this.formatConfigs(configs),
output: await this.getOutput(this.apiKey),
};
const result = await this.apiClient.delete(data);
if (this.syncErrors && this.syncErrors?.length > 0) {
await this.pushConfigs();
}
return result;
return await this.apiClient.delete(data);
}
async deleteAllConfigs() {

View file

@ -0,0 +1,31 @@
/*
* 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 { Request } from '@hapi/hapi';
import { CoreKibanaRequest } from '@kbn/core/server';
export function getFakeKibanaRequest(apiKey: { id: string; api_key: string }) {
const requestHeaders: Record<string, string> = {};
requestHeaders.authorization = `ApiKey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString(
'base64'
)}`;
return CoreKibanaRequest.from({
headers: requestHeaders,
path: '/',
route: { settings: {} },
url: {
href: '/',
},
raw: {
req: {
url: '/',
},
},
} as unknown as Request);
}

View file

@ -23,7 +23,7 @@ export default function ({ getService }: FtrProviderContext) {
describe('[GET] - /internal/uptime/service/enablement', () => {
['manage_security', 'manage_api_key', 'manage_own_api_key'].forEach((privilege) => {
it(`returns response for an admin with priviledge ${privilege}`, async () => {
it(`returns response for an admin with privilege ${privilege}`, async () => {
const username = 'admin';
const roleName = `synthetics_admin-${privilege}`;
const password = `${username}-password`;
@ -60,6 +60,7 @@ export default function ({ getService }: FtrProviderContext) {
canManageApiKeys: true,
canEnable: true,
isEnabled: false,
isValidApiKey: false,
});
} finally {
await security.user.delete(username);
@ -102,6 +103,7 @@ export default function ({ getService }: FtrProviderContext) {
canManageApiKeys: false,
canEnable: false,
isEnabled: false,
isValidApiKey: false,
});
} finally {
await security.role.delete(roleName);
@ -153,6 +155,7 @@ export default function ({ getService }: FtrProviderContext) {
canManageApiKeys: true,
canEnable: true,
isEnabled: true,
isValidApiKey: true,
});
} finally {
await supertest
@ -203,6 +206,7 @@ export default function ({ getService }: FtrProviderContext) {
canManageApiKeys: false,
canEnable: false,
isEnabled: false,
isValidApiKey: false,
});
} finally {
await security.user.delete(username);
@ -259,6 +263,7 @@ export default function ({ getService }: FtrProviderContext) {
canManageApiKeys: true,
canEnable: true,
isEnabled: false,
isValidApiKey: false,
});
} finally {
await security.user.delete(username);
@ -308,6 +313,7 @@ export default function ({ getService }: FtrProviderContext) {
canManageApiKeys: false,
canEnable: false,
isEnabled: true,
isValidApiKey: true,
});
} finally {
await supertestWithAuth
@ -370,6 +376,7 @@ export default function ({ getService }: FtrProviderContext) {
canManageApiKeys: true,
canEnable: true,
isEnabled: false,
isValidApiKey: false,
});
// can disable synthetics in non default space when enabled in default space
@ -394,6 +401,7 @@ export default function ({ getService }: FtrProviderContext) {
canManageApiKeys: true,
canEnable: true,
isEnabled: false,
isValidApiKey: false,
});
} finally {
await security.user.delete(username);