[Fleet] Encrypt ssl fields in logstash output (#129131)

This commit is contained in:
Nicolas Chaulet 2022-04-05 15:30:37 -04:00 committed by GitHub
parent ec06440a2f
commit 420359bacd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 216 additions and 21 deletions

View file

@ -229,6 +229,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => {
kibana: {
guide: `${KIBANA_DOCS}index.html`,
autocompleteSuggestions: `${KIBANA_DOCS}kibana-concepts-analysts.html#autocomplete-suggestions`,
secureSavedObject: `${KIBANA_DOCS}xpack-security-secure-saved-objects.html`,
xpackSecurity: `${KIBANA_DOCS}xpack-security.html`,
},
upgradeAssistant: {

View file

@ -213,6 +213,7 @@ export interface DocLinks {
readonly kibana: {
readonly guide: string;
readonly autocompleteSuggestions: string;
readonly secureSavedObject: string;
readonly xpackSecurity: string;
};
readonly upgradeAssistant: {

View file

@ -29,6 +29,7 @@ export interface NewOutput {
export type OutputSOAttributes = NewOutput & {
output_id?: string;
ssl?: string; // encrypted ssl field
};
export type Output = NewOutput & {

View file

@ -0,0 +1,43 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import { EuiCallOut, EuiLink } from '@elastic/eui';
import { useStartServices } from '../../../../hooks';
export const EncryptionKeyRequiredCallout: React.FunctionComponent = () => {
const { docLinks } = useStartServices();
return (
<EuiCallOut
iconType="alert"
color="warning"
title={
<FormattedMessage
id="xpack.fleet.encryptionKeyRequired.calloutTitle"
defaultMessage="Additional setup required"
/>
}
>
<FormattedMessage
id="xpack.fleet.encryptionKeyRequired.calloutDescription"
defaultMessage="You must configure an encryption key before configuring this output. {link}"
values={{
link: (
<EuiLink href={docLinks.links.kibana.secureSavedObject} target="_blank" external>
<FormattedMessage
id="xpack.fleet.encryptionKeyRequired.link"
defaultMessage="Learn more"
/>
</EuiLink>
),
}}
/>
</EuiCallOut>
);
};

View file

@ -9,6 +9,7 @@ import React from 'react';
import type { Output } from '../../../../types';
import { createFleetTestRendererMock } from '../../../../../../mock';
import { useFleetStatus } from '../../../../../../hooks/use_fleet_status';
import { EditOutputFlyout } from '.';
@ -20,8 +21,11 @@ jest.mock('../../../../../../hooks/use_fleet_status', () => ({
FleetStatusProvider: (props: any) => {
return props.children;
},
useFleetStatus: jest.fn().mockReturnValue({}),
}));
const mockedUsedFleetStatus = useFleetStatus as jest.MockedFunction<typeof useFleetStatus>;
function renderFlyout(output?: Output) {
const renderer = createFleetTestRendererMock();
@ -66,4 +70,20 @@ describe('EditOutputFlyout', () => {
expect(utils.queryByLabelText('Client SSL certificate')).not.toBeNull();
expect(utils.queryByLabelText('Server SSL certificate authorities')).not.toBeNull();
});
it('should show a callout in the flyout if the selected output is logstash and no encrypted key is set', async () => {
mockedUsedFleetStatus.mockReturnValue({
missingRequirements: ['encrypted_saved_object_encryption_key_required'],
} as any);
const { utils } = renderFlyout({
type: 'logstash',
name: 'logstash output',
id: 'output123',
is_default: false,
is_default_monitoring: false,
});
// Show logstash SSL inputs
expect(utils.getByText('Additional setup required')).not.toBeNull();
});
});

View file

@ -37,6 +37,7 @@ import { useBreadcrumbs, useStartServices } from '../../../../hooks';
import { YamlCodeEditorWithPlaceholder } from './yaml_code_editor_with_placeholder';
import { useOutputForm } from './use_output_form';
import { EncryptionKeyRequiredCallout } from './encryption_key_required_callout';
export interface EditOutputFlyoutProps {
output?: Output;
@ -60,6 +61,9 @@ export const EditOutputFlyout: React.FunctionComponent<EditOutputFlyoutProps> =
const isLogstashOutput = inputs.typeInput.value === 'logstash';
const isESOutput = inputs.typeInput.value === 'elasticsearch';
const showLogstashNeedEncryptedSavedObjectCallout =
isLogstashOutput && !form.hasEncryptedSavedObjectConfigured;
return (
<EuiFlyout maxWidth={FLYOUT_MAX_WIDTH} onClose={onClose}>
<EuiFlyoutHeader hasBorder={true}>
@ -160,6 +164,12 @@ export const EditOutputFlyout: React.FunctionComponent<EditOutputFlyoutProps> =
)}
/>
</EuiFormRow>
{showLogstashNeedEncryptedSavedObjectCallout && (
<>
<EuiSpacer size="m" />
<EncryptionKeyRequiredCallout />
</>
)}
{isLogstashOutput && (
<>
<EuiSpacer size="m" />

View file

@ -16,6 +16,7 @@ import {
useSwitchInput,
useStartServices,
sendPutOutput,
useFleetStatus,
} from '../../../../hooks';
import type { Output, PostOutputRequest } from '../../../../types';
import { useConfirmModal } from '../../hooks/use_confirm_modal';
@ -32,6 +33,12 @@ import {
import { confirmUpdate } from './confirm_update';
export function useOutputForm(onSucess: () => void, output?: Output) {
const fleetStatus = useFleetStatus();
const hasEncryptedSavedObjectConfigured = !fleetStatus.missingRequirements?.includes(
'encrypted_saved_object_encryption_key_required'
);
const [isLoading, setIsloading] = useState(false);
const { notifications } = useStartServices();
const { confirm } = useConfirmModal();
@ -234,6 +241,11 @@ export function useOutputForm(onSucess: () => void, output?: Output) {
inputs,
submit,
isLoading,
isDisabled: isLoading || isPreconfigured || (output && !hasChanged),
hasEncryptedSavedObjectConfigured,
isDisabled:
isLoading ||
isPreconfigured ||
(output && !hasChanged) ||
(isLogstash && !hasEncryptedSavedObjectConfigured),
};
}

View file

@ -51,7 +51,7 @@ export class HostedAgentPolicyRestrictionRelatedError extends IngestManagerError
);
}
}
export class FleetEncryptedSavedObjectEncryptionKeyRequired extends IngestManagerError {}
export class FleetSetupError extends IngestManagerError {}
export class GenerateServiceTokenError extends IngestManagerError {}
export class FleetUnauthorizedError extends IngestManagerError {}

View file

@ -7,6 +7,7 @@
import type { Observable } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import { take } from 'rxjs/operators';
import { i18n } from '@kbn/i18n';
import type {
@ -416,6 +417,8 @@ export class FleetPlugin
summary: 'Fleet is setting up',
});
await plugins.licensing.license$.pipe(take(1)).toPromise();
await setupFleet(
new SavedObjectsClient(core.savedObjects.createInternalRepository()),
core.elasticsearch.client.asInternalUser

View file

@ -21,17 +21,25 @@ export const getFleetStatusHandler: FleetRequestHandler = async (context, reques
context.core.elasticsearch.client.asInternalUser
);
let isReady = true;
const missingRequirements: GetFleetStatusResponse['missing_requirements'] = [];
if (!isApiKeysEnabled) {
isReady = false;
missingRequirements.push('api_keys');
}
if (!isFleetServerSetup) {
isReady = false;
missingRequirements.push('fleet_server');
}
if (!appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt) {
missingRequirements.push('encrypted_saved_object_encryption_key_required');
}
const body: GetFleetStatusResponse = {
isReady: missingRequirements.length === 0,
isReady,
missing_requirements: missingRequirements,
};

View file

@ -118,7 +118,7 @@ const getSavedObjectTypes = (
config: { type: 'flattened' },
config_yaml: { type: 'text' },
is_preconfigured: { type: 'boolean', index: false },
ssl: { type: 'flattened', index: false },
ssl: { type: 'binary' },
},
},
migrations: {
@ -310,5 +310,22 @@ export function registerSavedObjects(
export function registerEncryptedSavedObjects(
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup
) {
encryptedSavedObjects.registerType({
type: OUTPUT_SAVED_OBJECT_TYPE,
attributesToEncrypt: new Set([{ key: 'ssl', dangerouslyExposeValue: true }]),
attributesToExcludeFromAAD: new Set([
'output_id',
'name',
'type',
'is_default',
'is_default_monitoring',
'hosts',
'ca_sha256',
'ca_trusted_fingerprint',
'config',
'config_yaml',
'is_preconfigured',
]),
});
// Encrypted saved objects
}

View file

@ -161,6 +161,8 @@ function getMockedSoClient(
};
});
mockedAppContextService.getInternalUserSOClient.mockReturnValue(soClient);
return soClient;
}
@ -169,6 +171,8 @@ describe('Output Service', () => {
mockedAgentPolicyService.list.mockClear();
mockedAgentPolicyService.hasAPMIntegration.mockClear();
mockedAgentPolicyService.removeOutputFromAll.mockReset();
mockedAppContextService.getInternalUserSOClient.mockReset();
mockedAppContextService.getEncryptedSavedObjectsSetup.mockReset();
});
describe('create', () => {
it('work with a predefined id', async () => {
@ -321,6 +325,42 @@ describe('Output Service', () => {
{ is_default: false }
);
});
// With logstash output
it('should throw if encryptedSavedObject is not configured', async () => {
const soClient = getMockedSoClient({});
await expect(
outputService.create(
soClient,
{
is_default: false,
is_default_monitoring: false,
name: 'Test',
type: 'logstash',
},
{ id: 'output-test' }
)
).rejects.toThrow(`Logstash output needs encrypted saved object api key to be set`);
});
it('should work if encryptedSavedObject is configured', async () => {
const soClient = getMockedSoClient({});
mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({
canEncrypt: true,
} as any);
await outputService.create(
soClient,
{
is_default: false,
is_default_monitoring: false,
name: 'Test',
type: 'logstash',
},
{ id: 'output-test' }
);
expect(soClient.create).toBeCalled();
});
});
describe('update', () => {

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import type { SavedObject, SavedObjectsClientContract } from 'src/core/server';
import type { KibanaRequest, SavedObject, SavedObjectsClientContract } from 'src/core/server';
import uuid from 'uuid/v5';
import { omit } from 'lodash';
import type { NewOutput, Output, OutputSOAttributes } from '../types';
import {
@ -16,7 +17,11 @@ import {
AGENT_POLICY_SAVED_OBJECT_TYPE,
} from '../constants';
import { decodeCloudId, normalizeHostsForAgents, SO_SEARCH_LIMIT, outputType } from '../../common';
import { OutputUnauthorizedError, OutputInvalidError } from '../errors';
import {
OutputUnauthorizedError,
OutputInvalidError,
FleetEncryptedSavedObjectEncryptionKeyRequired,
} from '../errors';
import { agentPolicyService } from './agent_policy';
import { appContextService } from './app_context';
@ -27,6 +32,21 @@ const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE;
const DEFAULT_ES_HOSTS = ['http://localhost:9200'];
const fakeRequest = {
headers: {},
getBasePath: () => '',
path: '/',
route: { settings: {} },
url: {
href: '/',
},
raw: {
req: {
url: '/',
},
},
} as unknown as KibanaRequest;
// differentiate
function isUUID(val: string) {
return (
@ -45,10 +65,12 @@ export function outputIdToUuid(id: string) {
}
function outputSavedObjectToOutput(so: SavedObject<OutputSOAttributes>) {
const { output_id: outputId, ...atributes } = so.attributes;
const { output_id: outputId, ssl, ...atributes } = so.attributes;
return {
id: outputId ?? so.id,
...atributes,
...(ssl ? { ssl: JSON.parse(ssl as string) } : {}),
};
}
@ -86,8 +108,12 @@ async function validateLogstashOutputNotUsedInAPMPolicy(
}
class OutputService {
private get encryptedSoClient() {
return appContextService.getInternalUserSOClient(fakeRequest);
}
private async _getDefaultDataOutputsSO(soClient: SavedObjectsClientContract) {
return await soClient.find<OutputSOAttributes>({
return await this.encryptedSoClient.find<OutputSOAttributes>({
type: OUTPUT_SAVED_OBJECT_TYPE,
searchFields: ['is_default'],
search: 'true',
@ -95,7 +121,7 @@ class OutputService {
}
private async _getDefaultMonitoringOutputsSO(soClient: SavedObjectsClientContract) {
return await soClient.find<OutputSOAttributes>({
return await this.encryptedSoClient.find<OutputSOAttributes>({
type: OUTPUT_SAVED_OBJECT_TYPE,
searchFields: ['is_default_monitoring'],
search: 'true',
@ -164,10 +190,15 @@ class OutputService {
output: NewOutput,
options?: { id?: string; fromPreconfiguration?: boolean; overwrite?: boolean }
): Promise<Output> {
const data: OutputSOAttributes = { ...output };
const data: OutputSOAttributes = { ...omit(output, 'ssl') };
if (output.type === outputType.Logstash) {
await validateLogstashOutputNotUsedInAPMPolicy(soClient, undefined, data.is_default);
if (!appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt) {
throw new FleetEncryptedSavedObjectEncryptionKeyRequired(
'Logstash output needs encrypted saved object api key to be set'
);
}
}
// ensure only default output exists
@ -202,15 +233,16 @@ class OutputService {
data.output_id = options?.id;
}
const newSo = await soClient.create<OutputSOAttributes>(SAVED_OBJECT_TYPE, data, {
if (output.ssl) {
data.ssl = JSON.stringify(output.ssl);
}
const newSo = await this.encryptedSoClient.create<OutputSOAttributes>(SAVED_OBJECT_TYPE, data, {
overwrite: options?.overwrite || options?.fromPreconfiguration,
id: options?.id ? outputIdToUuid(options.id) : undefined,
});
return {
id: options?.id ?? newSo.id,
...newSo.attributes,
};
return outputSavedObjectToOutput(newSo);
}
public async bulkGet(
@ -218,7 +250,7 @@ class OutputService {
ids: string[],
{ ignoreNotFound = false } = { ignoreNotFound: true }
) {
const res = await soClient.bulkGet<OutputSOAttributes>(
const res = await this.encryptedSoClient.bulkGet<OutputSOAttributes>(
ids.map((id) => ({ id: outputIdToUuid(id), type: SAVED_OBJECT_TYPE }))
);
@ -237,7 +269,7 @@ class OutputService {
}
public async list(soClient: SavedObjectsClientContract) {
const outputs = await soClient.find<OutputSOAttributes>({
const outputs = await this.encryptedSoClient.find<OutputSOAttributes>({
type: SAVED_OBJECT_TYPE,
page: 1,
perPage: SO_SEARCH_LIMIT,
@ -254,7 +286,10 @@ class OutputService {
}
public async get(soClient: SavedObjectsClientContract, id: string): Promise<Output> {
const outputSO = await soClient.get<OutputSOAttributes>(SAVED_OBJECT_TYPE, outputIdToUuid(id));
const outputSO = await this.encryptedSoClient.get<OutputSOAttributes>(
SAVED_OBJECT_TYPE,
outputIdToUuid(id)
);
if (outputSO.error) {
throw new Error(outputSO.error.message);
@ -292,7 +327,7 @@ class OutputService {
id
);
return soClient.delete(SAVED_OBJECT_TYPE, outputIdToUuid(id));
return this.encryptedSoClient.delete(SAVED_OBJECT_TYPE, outputIdToUuid(id));
}
public async update(
@ -311,7 +346,7 @@ class OutputService {
);
}
const updateData: Nullable<Partial<Output>> = { ...data };
const updateData: Nullable<Partial<OutputSOAttributes>> = { ...omit(data, 'ssl') };
const mergedType = data.type ?? originalOutput.type;
const mergedIsDefault = data.is_default ?? originalOutput.is_default;
@ -331,6 +366,10 @@ class OutputService {
}
}
if (data.ssl) {
updateData.ssl = JSON.stringify(data.ssl);
}
// ensure only default output exists
if (data.is_default) {
const defaultDataOuputId = await this.getDefaultDataOutputId(soClient);
@ -359,7 +398,7 @@ class OutputService {
if (mergedType === outputType.Elasticsearch && updateData.hosts) {
updateData.hosts = updateData.hosts.map(normalizeHostsForAgents);
}
const outputSO = await soClient.update<Nullable<OutputSOAttributes>>(
const outputSO = await this.encryptedSoClient.update<Nullable<OutputSOAttributes>>(
SAVED_OBJECT_TYPE,
outputIdToUuid(id),
updateData