mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Fleet] Encrypt ssl fields in logstash output (#129131)
This commit is contained in:
parent
ec06440a2f
commit
420359bacd
13 changed files with 216 additions and 21 deletions
|
@ -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: {
|
||||
|
|
|
@ -213,6 +213,7 @@ export interface DocLinks {
|
|||
readonly kibana: {
|
||||
readonly guide: string;
|
||||
readonly autocompleteSuggestions: string;
|
||||
readonly secureSavedObject: string;
|
||||
readonly xpackSecurity: string;
|
||||
};
|
||||
readonly upgradeAssistant: {
|
||||
|
|
|
@ -29,6 +29,7 @@ export interface NewOutput {
|
|||
|
||||
export type OutputSOAttributes = NewOutput & {
|
||||
output_id?: string;
|
||||
ssl?: string; // encrypted ssl field
|
||||
};
|
||||
|
||||
export type Output = NewOutput & {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue