Skip to content

Commit

Permalink
[Beats Management] APIs: Enroll beat (#19056)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ycombinator authored May 16, 2018
1 parent 963d56b commit 57a7181
Show file tree
Hide file tree
Showing 10 changed files with 315 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/beats/server/lib/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@
"id": {
"type": "keyword"
},
"enrollment_token": {
"type": "keyword"
},
"access_token": {
"type": "keyword"
},
Expand All @@ -58,7 +55,7 @@
"type": "keyword"
},
"host_ip": {
"type": "keyword"
"type": "ip"
},
"host_name": {
"type": "keyword"
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/beats/server/routes/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
104 changes: 104 additions & 0 deletions x-pack/plugins/beats/server/routes/api/register_enroll_beat_route.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

19 changes: 5 additions & 14 deletions x-pack/test/api_integration/apis/beats/create_enrollment_tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
183 changes: 183 additions & 0 deletions x-pack/test/api_integration/apis/beats/enroll_beat.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading

0 comments on commit 57a7181

Please sign in to comment.