From 57a71811068f871e001e4be6d4c0e3553dec1010 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Wed, 16 May 2018 08:31:57 -0700 Subject: [PATCH] [Beats Management] APIs: Enroll beat (#19056) * WIP checkin * Add API integration test * Converting to Jest test * Create API for enrolling a beat * Handle invalid or expired enrollment tokens * Use create instead of index to prevent same beat from being enrolled twice * Adding unit test for duplicate beat enrollment * Do not persist enrollment token with beat once token has been checked and used * Fix datatype of host_ip field * Make Kibana API guess host IP instead of requiring it in payload * Fixing error introduced in rebase conflict resolution --- .../call_with_request_factory.js | 5 +- .../plugins/beats/server/lib/client/index.js | 1 + .../lib/index_template/beats_template.json | 5 +- .../plugins/beats/server/routes/api/index.js | 2 + ...register_create_enrollment_tokens_route.js | 2 +- .../routes/api/register_enroll_beat_route.js | 104 ++++++++++ .../api_integration/apis/beats/constants.js} | 4 +- .../apis/beats/create_enrollment_tokens.js | 19 +- .../api_integration/apis/beats/enroll_beat.js | 183 ++++++++++++++++++ .../test/api_integration/apis/beats/index.js | 14 +- 10 files changed, 315 insertions(+), 24 deletions(-) rename x-pack/plugins/beats/server/lib/{call_with_request_factory => client}/call_with_request_factory.js (73%) create mode 100644 x-pack/plugins/beats/server/routes/api/register_enroll_beat_route.js rename x-pack/{plugins/beats/server/lib/call_with_request_factory/index.js => test/api_integration/apis/beats/constants.js} (73%) create mode 100644 x-pack/test/api_integration/apis/beats/enroll_beat.js diff --git a/x-pack/plugins/beats/server/lib/call_with_request_factory/call_with_request_factory.js b/x-pack/plugins/beats/server/lib/client/call_with_request_factory.js similarity index 73% rename from x-pack/plugins/beats/server/lib/call_with_request_factory/call_with_request_factory.js rename to x-pack/plugins/beats/server/lib/client/call_with_request_factory.js index 0c4f909d12f61..c81670ed0cdec 100644 --- a/x-pack/plugins/beats/server/lib/call_with_request_factory/call_with_request_factory.js +++ b/x-pack/plugins/beats/server/lib/client/call_with_request_factory.js @@ -7,9 +7,8 @@ import { once } from 'lodash'; const callWithRequest = once((server) => { - const config = server.config().get('elasticsearch'); - const cluster = server.plugins.elasticsearch.createCluster('beats', config); - return cluster.callWithRequest; + const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); + return callWithRequest; }); export const callWithRequestFactory = (server, request) => { diff --git a/x-pack/plugins/beats/server/lib/client/index.js b/x-pack/plugins/beats/server/lib/client/index.js index a56a50e2864a5..cdeee091cc66f 100644 --- a/x-pack/plugins/beats/server/lib/client/index.js +++ b/x-pack/plugins/beats/server/lib/client/index.js @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ +export { callWithRequestFactory } from './call_with_request_factory'; export { callWithInternalUserFactory } from './call_with_internal_user_factory'; diff --git a/x-pack/plugins/beats/server/lib/index_template/beats_template.json b/x-pack/plugins/beats/server/lib/index_template/beats_template.json index 6ef57b9c549ed..9b37b7e816bf8 100644 --- a/x-pack/plugins/beats/server/lib/index_template/beats_template.json +++ b/x-pack/plugins/beats/server/lib/index_template/beats_template.json @@ -45,9 +45,6 @@ "id": { "type": "keyword" }, - "enrollment_token": { - "type": "keyword" - }, "access_token": { "type": "keyword" }, @@ -58,7 +55,7 @@ "type": "keyword" }, "host_ip": { - "type": "keyword" + "type": "ip" }, "host_name": { "type": "keyword" diff --git a/x-pack/plugins/beats/server/routes/api/index.js b/x-pack/plugins/beats/server/routes/api/index.js index 8bf546045fe40..07d923876ee79 100644 --- a/x-pack/plugins/beats/server/routes/api/index.js +++ b/x-pack/plugins/beats/server/routes/api/index.js @@ -5,7 +5,9 @@ */ import { registerCreateEnrollmentTokensRoute } from './register_create_enrollment_tokens_route'; +import { registerEnrollBeatRoute } from './register_enroll_beat_route'; export function registerApiRoutes(server) { registerCreateEnrollmentTokensRoute(server); + registerEnrollBeatRoute(server); } diff --git a/x-pack/plugins/beats/server/routes/api/register_create_enrollment_tokens_route.js b/x-pack/plugins/beats/server/routes/api/register_create_enrollment_tokens_route.js index 582ae59062d8b..87ae30cd0e532 100644 --- a/x-pack/plugins/beats/server/routes/api/register_create_enrollment_tokens_route.js +++ b/x-pack/plugins/beats/server/routes/api/register_create_enrollment_tokens_route.js @@ -12,7 +12,7 @@ import { flatten } from 'lodash'; import { INDEX_NAMES } from '../../../common/constants'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { callWithRequestFactory } from '../../lib/client'; import { wrapEsError } from '../../lib/error_wrappers'; function persistTokens(callWithRequest, tokens, enrollmentTokensTtlInSeconds) { diff --git a/x-pack/plugins/beats/server/routes/api/register_enroll_beat_route.js b/x-pack/plugins/beats/server/routes/api/register_enroll_beat_route.js new file mode 100644 index 0000000000000..fb004fbb79e12 --- /dev/null +++ b/x-pack/plugins/beats/server/routes/api/register_enroll_beat_route.js @@ -0,0 +1,104 @@ +/* + * 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 Joi from 'joi'; +import uuid from 'uuid'; +import moment from 'moment'; +import { + get, + omit +} from 'lodash'; +import { INDEX_NAMES } from '../../../common/constants'; +import { callWithInternalUserFactory } from '../../lib/client'; +import { wrapEsError } from '../../lib/error_wrappers'; + +async function getEnrollmentToken(callWithInternalUser, enrollmentToken) { + const params = { + index: INDEX_NAMES.BEATS, + type: '_doc', + id: `enrollment_token:${enrollmentToken}`, + ignore: [ 404 ] + }; + + const response = await callWithInternalUser('get', params); + return get(response, '_source.enrollment_token', {}); +} + +function deleteUsedEnrollmentToken(callWithInternalUser, enrollmentToken) { + const params = { + index: INDEX_NAMES.BEATS, + type: '_doc', + id: `enrollment_token:${enrollmentToken}` + }; + + return callWithInternalUser('delete', params); +} + +function persistBeat(callWithInternalUser, beat, beatId, accessToken, remoteAddress) { + const body = { + type: 'beat', + beat: { + ...omit(beat, 'enrollment_token'), + id: beatId, + access_token: accessToken, + host_ip: remoteAddress + } + }; + + const params = { + index: INDEX_NAMES.BEATS, + type: '_doc', + id: `beat:${beatId}`, + body, + refresh: 'wait_for' + }; + return callWithInternalUser('create', params); +} + +// TODO: add license check pre-hook +// TODO: write to Kibana audit log file +export function registerEnrollBeatRoute(server) { + server.route({ + method: 'POST', + path: '/api/beats/agent/{beatId}', + config: { + validate: { + payload: Joi.object({ + enrollment_token: Joi.string().required(), + type: Joi.string().required(), + host_name: Joi.string().required() + }).required() + }, + auth: false + }, + handler: async (request, reply) => { + const callWithInternalUser = callWithInternalUserFactory(server); + let accessToken; + + try { + const enrollmentToken = request.payload.enrollment_token; + const { token, expires_on: expiresOn } = await getEnrollmentToken(callWithInternalUser, enrollmentToken); + if (!token || token !== enrollmentToken) { + return reply({ message: 'Invalid enrollment token' }).code(400); + } + if (moment(expiresOn).isBefore(moment())) { + return reply({ message: 'Expired enrollment token' }).code(400); + } + + accessToken = uuid.v4().replace(/-/g, ""); + const remoteAddress = request.info.remoteAddress; + await persistBeat(callWithInternalUser, request.payload, request.params.beatId, accessToken, remoteAddress); + + await deleteUsedEnrollmentToken(callWithInternalUser, enrollmentToken); + } catch (err) { + return reply(wrapEsError(err)); + } + + const response = { access_token: accessToken }; + reply(response).code(201); + } + }); +} diff --git a/x-pack/plugins/beats/server/lib/call_with_request_factory/index.js b/x-pack/test/api_integration/apis/beats/constants.js similarity index 73% rename from x-pack/plugins/beats/server/lib/call_with_request_factory/index.js rename to x-pack/test/api_integration/apis/beats/constants.js index 787814d87dff9..00327aface171 100644 --- a/x-pack/plugins/beats/server/lib/call_with_request_factory/index.js +++ b/x-pack/test/api_integration/apis/beats/constants.js @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { callWithRequestFactory } from './call_with_request_factory'; +export const ES_INDEX_NAME = '.management-beats'; +export const ES_TYPE_NAME = '_doc'; + diff --git a/x-pack/test/api_integration/apis/beats/create_enrollment_tokens.js b/x-pack/test/api_integration/apis/beats/create_enrollment_tokens.js index a12849d7a1c34..86b80323773b4 100644 --- a/x-pack/test/api_integration/apis/beats/create_enrollment_tokens.js +++ b/x-pack/test/api_integration/apis/beats/create_enrollment_tokens.js @@ -6,26 +6,17 @@ import expect from 'expect.js'; import moment from 'moment'; +import { + ES_INDEX_NAME, + ES_TYPE_NAME +} from './constants'; export default function ({ getService }) { const supertest = getService('supertest'); const chance = getService('chance'); const es = getService('es'); - const ES_INDEX_NAME = '.management-beats'; - const ES_TYPE_NAME = '_doc'; - - describe('create_enrollment_tokens', () => { - const cleanup = () => { - return es.indices.delete({ - index: ES_INDEX_NAME, - ignore: [ 404 ] - }); - }; - - beforeEach(cleanup); - afterEach(cleanup); - + describe('create_enrollment_token', () => { it('should create one token by default', async () => { const { body: apiResponse } = await supertest .post( diff --git a/x-pack/test/api_integration/apis/beats/enroll_beat.js b/x-pack/test/api_integration/apis/beats/enroll_beat.js new file mode 100644 index 0000000000000..ec3785f8eb35d --- /dev/null +++ b/x-pack/test/api_integration/apis/beats/enroll_beat.js @@ -0,0 +1,183 @@ +/* + * 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 expect from 'expect.js'; +import moment from 'moment'; +import { + ES_INDEX_NAME, + ES_TYPE_NAME +} from './constants'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const chance = getService('chance'); + const es = getService('es'); + + describe('enroll_beat', () => { + let validEnrollmentToken; + let beatId; + let beat; + + beforeEach(async () => { + validEnrollmentToken = chance.word(); + beatId = chance.word(); + beat = { + enrollment_token: validEnrollmentToken, + type: 'filebeat', + host_name: 'foo.bar.com', + }; + + await es.index({ + index: ES_INDEX_NAME, + type: ES_TYPE_NAME, + id: `enrollment_token:${validEnrollmentToken}`, + body: { + type: 'enrollment_token', + enrollment_token: { + token: validEnrollmentToken, + expires_on: moment().add(4, 'hours').toJSON() + } + } + }); + }); + + it('should enroll beat in an unverified state', async () => { + await supertest + .post( + `/api/beats/agent/${beatId}` + ) + .set('kbn-xsrf', 'xxx') + .send(beat) + .expect(201); + + const esResponse = await es.get({ + index: ES_INDEX_NAME, + type: ES_TYPE_NAME, + id: `beat:${beatId}` + }); + + expect(esResponse._source.beat).to.not.have.property('verified_on'); + expect(esResponse._source.beat).to.have.property('host_ip'); + }); + + it('should contain an access token in the response', async () => { + const { body: apiResponse } = await supertest + .post( + `/api/beats/agent/${beatId}` + ) + .set('kbn-xsrf', 'xxx') + .send(beat) + .expect(201); + + const accessTokenFromApi = apiResponse.access_token; + + const esResponse = await es.get({ + index: ES_INDEX_NAME, + type: ES_TYPE_NAME, + id: `beat:${beatId}` + }); + + const accessTokenInEs = esResponse._source.beat.access_token; + + expect(accessTokenFromApi.length).to.be.greaterThan(0); + expect(accessTokenFromApi).to.eql(accessTokenInEs); + }); + + it('should reject an invalid enrollment token', async () => { + const invalidEnrollmentToken = chance.word(); + beat.enrollment_token = invalidEnrollmentToken; + + const { body: apiResponse } = await supertest + .post( + `/api/beats/agent/${beatId}` + ) + .set('kbn-xsrf', 'xxx') + .send(beat) + .expect(400); + + expect(apiResponse).to.eql({ message: 'Invalid enrollment token' }); + }); + + it('should reject an expired enrollment token', async () => { + const expiredEnrollmentToken = chance.word(); + + await es.index({ + index: ES_INDEX_NAME, + type: ES_TYPE_NAME, + id: `enrollment_token:${expiredEnrollmentToken}`, + body: { + type: 'enrollment_token', + enrollment_token: { + token: expiredEnrollmentToken, + expires_on: moment().subtract(1, 'minute').toJSON() + } + } + }); + + beat.enrollment_token = expiredEnrollmentToken; + + const { body: apiResponse } = await supertest + .post( + `/api/beats/agent/${beatId}` + ) + .set('kbn-xsrf', 'xxx') + .send(beat) + .expect(400); + + expect(apiResponse).to.eql({ message: 'Expired enrollment token' }); + }); + + it('should delete the given enrollment token so it may not be reused', async () => { + await supertest + .post( + `/api/beats/agent/${beatId}` + ) + .set('kbn-xsrf', 'xxx') + .send(beat) + .expect(201); + + const esResponse = await es.get({ + index: ES_INDEX_NAME, + type: ES_TYPE_NAME, + id: `enrollment_token:${validEnrollmentToken}`, + ignore: [ 404 ] + }); + + expect(esResponse.found).to.be(false); + }); + + it('should fail if the beat with the same ID is enrolled twice', async () => { + await supertest + .post( + `/api/beats/agent/${beatId}` + ) + .set('kbn-xsrf', 'xxx') + .send(beat) + .expect(201); + + await es.index({ + index: ES_INDEX_NAME, + type: ES_TYPE_NAME, + id: `enrollment_token:${validEnrollmentToken}`, + body: { + type: 'enrollment_token', + enrollment_token: { + token: validEnrollmentToken, + expires_on: moment().add(4, 'hours').toJSON() + } + } + }); + + await supertest + .post( + `/api/beats/agent/${beatId}` + ) + .set('kbn-xsrf', 'xxx') + .send(beat) + .expect(409); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/beats/index.js b/x-pack/test/api_integration/apis/beats/index.js index f8344895f02aa..dc6137f979019 100644 --- a/x-pack/test/api_integration/apis/beats/index.js +++ b/x-pack/test/api_integration/apis/beats/index.js @@ -4,8 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -export default function ({ loadTestFile }) { +import { ES_INDEX_NAME } from './constants'; + +export default function ({ getService, loadTestFile }) { + const es = getService('es'); + describe('beats', () => { + const cleanup = () => es.indices.delete({ + index: ES_INDEX_NAME, + ignore: [ 404 ] + }); + + beforeEach(cleanup); + loadTestFile(require.resolve('./create_enrollment_tokens')); + loadTestFile(require.resolve('./enroll_beat')); }); }