Skip to content

Commit

Permalink
Encrypt fleet_server in output saved object
Browse files Browse the repository at this point in the history
  • Loading branch information
nchaulet committed Sep 10, 2021
1 parent 0ed4842 commit 5e75149
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 52 deletions.
4 changes: 3 additions & 1 deletion x-pack/plugins/fleet/common/types/models/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export interface NewOutput {
};
}

export type OutputSOAttributes = NewOutput;
export type OutputSOAttributes = NewOutput & {
output_id?: string;
};

export type Output = NewOutput & {
id: string;
Expand Down
25 changes: 18 additions & 7 deletions x-pack/plugins/fleet/server/saved_objects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ const getSavedObjectTypes = (
},
mappings: {
properties: {
output_id: { type: 'keyword' },
name: { type: 'keyword' },
type: { type: 'keyword' },
is_default: { type: 'boolean' },
Expand All @@ -206,13 +207,7 @@ const getSavedObjectTypes = (
config: { type: 'flattened', index: false },
config_yaml: { type: 'text', index: false },
is_preconfigured: { type: 'boolean' },
fleet_server: {
type: 'nested',
enabled: false,
properties: {
service_token: { type: 'text', index: false },
},
},
fleet_server: { type: 'binary' },
},
},
migrations: {
Expand Down Expand Up @@ -427,4 +422,20 @@ export function registerEncryptedSavedObjects(
attributesToEncrypt: new Set(['data']),
attributesToExcludeFromAAD: new Set(['agent_id', 'type', 'sent_at', 'created_at']),
});

encryptedSavedObjects.registerType({
type: OUTPUT_SAVED_OBJECT_TYPE,
attributesToEncrypt: new Set(['fleet_server']),
attributesToExcludeFromAAD: new Set([
'output_id',
'name',
'type',
'is_default',
'hosts',
'ca_sha256',
'config',
'config_yaml',
'is_preconfigured',
]),
});
}
77 changes: 76 additions & 1 deletion x-pack/plugins/fleet/server/services/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
* 2.0.
*/

import { outputService } from './output';
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks';
import type { OutputSOAttributes } from '../types';
import type { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server';

import { outputService, outputIdToUuid } from './output';
import { appContextService } from './app_context';

jest.mock('./app_context');
Expand Down Expand Up @@ -34,7 +38,78 @@ const CONFIG_WITHOUT_ES_HOSTS = {
},
};

let mockedEncryptedSO: jest.Mocked<EncryptedSavedObjectsClient>;

describe('Output Service', () => {
beforeEach(() => {
mockedEncryptedSO = encryptedSavedObjectsMock.createClient();
mockedAppContextService.getEncryptedSavedObjects.mockReturnValue(mockedEncryptedSO);
mockedEncryptedSO.getDecryptedAsInternalUser.mockImplementation(
async (type: string, id: string) => {
switch (id) {
case outputIdToUuid('output-test'): {
return {
id: outputIdToUuid('output-test'),
type: 'ingest-outputs',
references: [],
attributes: {
output_id: 'output-test',
},
};
}
default:
throw new Error('not found');
}
}
);
});

afterEach(() => {
mockedAppContextService.getEncryptedSavedObjects.mockRestore();
});
describe('create', () => {
it('work with a predefined id', async () => {
const soClient = savedObjectsClientMock.create();
soClient.create.mockResolvedValue({
id: outputIdToUuid('output-test'),
type: 'ingest-output',
attributes: {},
references: [],
});
await outputService.create(
soClient,
{
is_default: false,
name: 'Test',
type: 'elasticsearch',
},
{ id: 'output-test' }
);

expect(soClient.create).toBeCalled();

// ID should always be the same for a predefined id
expect(soClient.create.mock.calls[0][2]?.id).toEqual(outputIdToUuid('output-test'));
expect((soClient.create.mock.calls[0][1] as OutputSOAttributes).output_id).toEqual(
'output-test'
);
});
});

describe('get', () => {
it('work with a predefined id', async () => {
const soClient = savedObjectsClientMock.create();
const output = await outputService.get(soClient, 'output-test');

expect(mockedEncryptedSO.getDecryptedAsInternalUser).toHaveBeenCalledWith(
'ingest-outputs',
outputIdToUuid('output-test')
);

expect(output.id).toEqual('output-test');
});
});

describe('getDefaultESHosts', () => {
afterEach(() => {
mockedAppContextService.getConfig.mockReset();
Expand Down
76 changes: 47 additions & 29 deletions x-pack/plugins/fleet/server/services/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* 2.0.
*/

import type { SavedObjectsClientContract } from 'src/core/server';
import type { SavedObject, SavedObjectsClientContract } from 'src/core/server';
import uuid from 'uuid/v5';

import type { NewOutput, Output, OutputSOAttributes } from '../types';
import { DEFAULT_OUTPUT, OUTPUT_SAVED_OBJECT_TYPE } from '../constants';
Expand All @@ -17,6 +18,30 @@ const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE;

const DEFAULT_ES_HOSTS = ['http://localhost:9200'];

// differentiate
function isUUID(val: string) {
return (
typeof val === 'string' &&
val.match(/[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}/)
);
}

export function outputIdToUuid(id: string) {
if (isUUID(id)) {
return id;
}

return uuid(id, uuid.DNS);
}

function outputSavedObjectToOutput(so: SavedObject<OutputSOAttributes>) {
const { output_id: outputId, ...atributes } = so.attributes;
return {
id: outputId ?? so.id,
...atributes,
};
}

class OutputService {
public async getDefaultOutput(soClient: SavedObjectsClientContract) {
return await soClient.find<OutputSOAttributes>({
Expand All @@ -39,10 +64,7 @@ class OutputService {
return await this.create(soClient, newDefaultOutput);
}

return {
id: outputs.saved_objects[0].id,
...outputs.saved_objects[0].attributes,
};
return outputSavedObjectToOutput(outputs.saved_objects[0]);
}

public getDefaultESHosts(): string[] {
Expand Down Expand Up @@ -74,7 +96,7 @@ class OutputService {
output: NewOutput,
options?: { id?: string }
): Promise<Output> {
const data = { ...output };
const data: OutputSOAttributes = { ...output };

// ensure only default output exists
if (data.is_default) {
Expand All @@ -88,33 +110,36 @@ class OutputService {
data.hosts = data.hosts.map(normalizeHostsForAgents);
}

if (options?.id) {
data.output_id = options?.id;
}

const newSo = await soClient.create<OutputSOAttributes>(
SAVED_OBJECT_TYPE,
data as Output,
options
data,
options?.id ? { id: outputIdToUuid(options.id) } : undefined
);

return {
id: newSo.id,
id: options?.id ?? newSo.id,
...newSo.attributes,
};
}

public async get(soClient: SavedObjectsClientContract, id: string): Promise<Output> {
const outputSO = await soClient.get<OutputSOAttributes>(SAVED_OBJECT_TYPE, id);
const outputSO = await appContextService
.getEncryptedSavedObjects()
.getDecryptedAsInternalUser<OutputSOAttributes>(SAVED_OBJECT_TYPE, outputIdToUuid(id));

if (outputSO.error) {
throw new Error(outputSO.error.message);
}

return {
id: outputSO.id,
...outputSO.attributes,
};
return outputSavedObjectToOutput(outputSO);
}

public async delete(soClient: SavedObjectsClientContract, id: string) {
return soClient.delete(SAVED_OBJECT_TYPE, id);
return soClient.delete(SAVED_OBJECT_TYPE, outputIdToUuid(id));
}

public async update(soClient: SavedObjectsClientContract, id: string, data: Partial<Output>) {
Expand All @@ -123,8 +148,11 @@ class OutputService {
if (updateData.hosts) {
updateData.hosts = updateData.hosts.map(normalizeHostsForAgents);
}

const outputSO = await soClient.update<OutputSOAttributes>(SAVED_OBJECT_TYPE, id, updateData);
const outputSO = await soClient.update<OutputSOAttributes>(
SAVED_OBJECT_TYPE,
outputIdToUuid(id),
updateData
);

if (outputSO.error) {
throw new Error(outputSO.error.message);
Expand All @@ -140,12 +168,7 @@ class OutputService {
});

return {
items: outputs.saved_objects.map<Output>((outputSO) => {
return {
id: outputSO.id,
...outputSO.attributes,
};
}),
items: outputs.saved_objects.map<Output>(outputSavedObjectToOutput),
total: outputs.total,
page: 1,
perPage: 10000,
Expand All @@ -160,12 +183,7 @@ class OutputService {
});

return {
items: outputs.saved_objects.map<Output>((outputSO) => {
return {
id: outputSO.id,
...outputSO.attributes,
};
}),
items: outputs.saved_objects.map<Output>(outputSavedObjectToOutput),
total: outputs.total,
page: 1,
perPage: 1000,
Expand Down
63 changes: 52 additions & 11 deletions x-pack/plugins/fleet/server/services/preconfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/serve

import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';

import type { PreconfiguredAgentPolicy } from '../../common/types';
import type { PreconfiguredAgentPolicy, PreconfiguredOutput } from '../../common/types';
import type { AgentPolicy, NewPackagePolicy, Output } from '../types';

import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants';
Expand Down Expand Up @@ -510,9 +510,20 @@ describe('output preconfiguration', () => {
name: 'Output 1',
// @ts-ignore
type: 'elasticsearch',
hosts: ['http://es.co:9201'],
hosts: ['http://es.co:80'],
is_preconfigured: true,
};
case 'existing-output-fleet-server-service-token':
return {
id: 'existing-output-1',
is_default: false,
name: 'Output 1',
// @ts-ignore
type: 'elasticsearch',
hosts: ['http://es.co:80'],
is_preconfigured: true,
fleet_server: { service_token: 'test123' },
};
default:
throw soClient.errors.createGenericNotFoundError(id);
}
Expand Down Expand Up @@ -567,20 +578,50 @@ describe('output preconfiguration', () => {
expect(mockedOutputService.update).toBeCalled();
});

it('should do nothing if preconfigured output exists and did not changed', async () => {
const soClient = savedObjectsClientMock.create();
await ensurePreconfiguredOutputs(soClient, [
{
const SCENARIOS: Array<{ name: string; data: PreconfiguredOutput }> = [
{
name: 'no changes',
data: {
id: 'existing-output-1',
is_default: false,
name: 'Output 1',
type: 'elasticsearch',
hosts: ['http://es.co:9201'],
hosts: ['http://es.co:80'],
},
]);

expect(mockedOutputService.create).not.toBeCalled();
expect(mockedOutputService.update).not.toBeCalled();
},
{
name: 'hosts without port',
data: {
id: 'existing-output-1',
is_default: false,
name: 'Output 1',
type: 'elasticsearch',
hosts: ['http://es.co'],
},
},
{
name: 'with fleet server service token',
data: {
id: 'existing-output-fleet-server-service-token',
is_default: false,
name: 'Output 1',
type: 'elasticsearch',
hosts: ['http://es.co:80'],
fleet_server: {
service_token: 'test123',
},
},
},
];
SCENARIOS.forEach((scenario) => {
const { data, name } = scenario;
it(`should do nothing if preconfigured output exists and did not changed (${name})`, async () => {
const soClient = savedObjectsClientMock.create();
await ensurePreconfiguredOutputs(soClient, [data]);

expect(mockedOutputService.create).not.toBeCalled();
expect(mockedOutputService.update).not.toBeCalled();
});
});

it('should not delete non deleted preconfigured output', async () => {
Expand Down
Loading

0 comments on commit 5e75149

Please sign in to comment.