-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Beats Management] APIs: Update beat #19148
Changes from all commits
5f367e7
60cfd96
d40042a
ed3e29d
19d893d
ccc117b
f73bf3b
ff35c6f
be53b05
965a4e3
481f96f
71a4d83
12d908c
ab2a0ec
d14642c
35d44d1
e09f587
63747a3
6940d3d
b565aec
dcbeec6
08a46b6
1b54984
add6da3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -54,6 +54,9 @@ | |
"type": { | ||
"type": "keyword" | ||
}, | ||
"version": { | ||
"type": "keyword" | ||
}, | ||
"host_ip": { | ||
"type": "ip" | ||
}, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
/* | ||
* 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 { get } from 'lodash'; | ||
import { INDEX_NAMES } from '../../../common/constants'; | ||
import { callWithInternalUserFactory } from '../../lib/client'; | ||
import { wrapEsError } from '../../lib/error_wrappers'; | ||
|
||
async function getBeat(callWithInternalUser, beatId) { | ||
const params = { | ||
index: INDEX_NAMES.BEATS, | ||
type: '_doc', | ||
id: `beat:${beatId}`, | ||
ignore: [ 404 ] | ||
}; | ||
|
||
const response = await callWithInternalUser('get', params); | ||
if (!response.found) { | ||
return null; | ||
} | ||
|
||
return get(response, '_source.beat'); | ||
} | ||
|
||
function persistBeat(callWithInternalUser, beat) { | ||
const body = { | ||
type: 'beat', | ||
beat | ||
}; | ||
|
||
const params = { | ||
index: INDEX_NAMES.BEATS, | ||
type: '_doc', | ||
id: `beat:${beat.id}`, | ||
body, | ||
refresh: 'wait_for' | ||
}; | ||
return callWithInternalUser('index', params); | ||
} | ||
|
||
// TODO: add license check pre-hook | ||
// TODO: write to Kibana audit log file (include who did the verification as well) | ||
export function registerUpdateBeatRoute(server) { | ||
server.route({ | ||
method: 'PUT', | ||
path: '/api/beats/agent/{beatId}', | ||
config: { | ||
validate: { | ||
payload: Joi.object({ | ||
access_token: Joi.string().required(), | ||
type: Joi.string(), | ||
version: Joi.string(), | ||
host_name: Joi.string(), | ||
ephemeral_id: Joi.string(), | ||
local_configuration_yml: Joi.string(), | ||
metadata: Joi.object() | ||
}).required() | ||
}, | ||
auth: false | ||
}, | ||
handler: async (request, reply) => { | ||
const callWithInternalUser = callWithInternalUserFactory(server); | ||
const { beatId } = request.params; | ||
|
||
try { | ||
const beat = await getBeat(callWithInternalUser, beatId); | ||
if (beat === null) { | ||
return reply({ message: 'Beat not found' }).code(404); | ||
} | ||
|
||
const isAccessTokenValid = beat.access_token === request.payload.access_token; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comparison is susceptible to timing attacks: https://codahale.com/a-lesson-in-timing-attacks/ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks! I just found https://nodejs.org/api/crypto.html#crypto_crypto_timingsafeequal_a_b and will use that here (and other similar places) instead. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in #19363. |
||
if (!isAccessTokenValid) { | ||
return reply({ message: 'Invalid access token' }).code(401); | ||
} | ||
|
||
const isBeatVerified = beat.hasOwnProperty('verified_on'); | ||
if (!isBeatVerified) { | ||
return reply({ message: 'Beat has not been verified' }).code(400); | ||
} | ||
|
||
const remoteAddress = request.info.remoteAddress; | ||
await persistBeat(callWithInternalUser, { | ||
...beat, | ||
...request.payload, | ||
host_ip: remoteAddress | ||
}); | ||
} catch (err) { | ||
return reply(wrapEsError(err)); | ||
} | ||
|
||
reply().code(204); | ||
} | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
/* | ||
* 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 { | ||
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 esArchiver = getService('esArchiver'); | ||
|
||
describe('update_beat', () => { | ||
let beat; | ||
const archive = 'beats/list'; | ||
|
||
beforeEach('load beats archive', () => esArchiver.load(archive)); | ||
beforeEach(() => { | ||
const version = chance.integer({ min: 1, max: 10 }) | ||
+ '.' | ||
+ chance.integer({ min: 1, max: 10 }) | ||
+ '.' | ||
+ chance.integer({ min: 1, max: 10 }); | ||
|
||
beat = { | ||
access_token: '93c4a4dd08564c189a7ec4e4f046b975', | ||
type: `${chance.word()}beat`, | ||
host_name: `www.${chance.word()}.net`, | ||
version, | ||
ephemeral_id: chance.word() | ||
}; | ||
}); | ||
|
||
afterEach('unload beats archive', () => esArchiver.unload(archive)); | ||
|
||
it('should update an existing verified beat', async () => { | ||
const beatId = 'foo'; | ||
await supertest | ||
.put( | ||
`/api/beats/agent/${beatId}` | ||
) | ||
.set('kbn-xsrf', 'xxx') | ||
.send(beat) | ||
.expect(204); | ||
|
||
const beatInEs = await es.get({ | ||
index: ES_INDEX_NAME, | ||
type: ES_TYPE_NAME, | ||
id: `beat:${beatId}` | ||
}); | ||
|
||
expect(beatInEs._source.beat.id).to.be(beatId); | ||
expect(beatInEs._source.beat.type).to.be(beat.type); | ||
expect(beatInEs._source.beat.host_name).to.be(beat.host_name); | ||
expect(beatInEs._source.beat.version).to.be(beat.version); | ||
expect(beatInEs._source.beat.ephemeral_id).to.be(beat.ephemeral_id); | ||
}); | ||
|
||
it('should return an error for an invalid access token', async () => { | ||
const beatId = 'foo'; | ||
beat.access_token = chance.word(); | ||
const { body } = await supertest | ||
.put( | ||
`/api/beats/agent/${beatId}` | ||
) | ||
.set('kbn-xsrf', 'xxx') | ||
.send(beat) | ||
.expect(401); | ||
|
||
expect(body.message).to.be('Invalid access token'); | ||
|
||
const beatInEs = await es.get({ | ||
index: ES_INDEX_NAME, | ||
type: ES_TYPE_NAME, | ||
id: `beat:${beatId}` | ||
}); | ||
|
||
expect(beatInEs._source.beat.id).to.be(beatId); | ||
expect(beatInEs._source.beat.type).to.not.be(beat.type); | ||
expect(beatInEs._source.beat.host_name).to.not.be(beat.host_name); | ||
expect(beatInEs._source.beat.version).to.not.be(beat.version); | ||
expect(beatInEs._source.beat.ephemeral_id).to.not.be(beat.ephemeral_id); | ||
}); | ||
|
||
it('should return an error for an existing but unverified beat', async () => { | ||
const beatId = 'bar'; | ||
beat.access_token = '3c4a4dd08564c189a7ec4e4f046b9759'; | ||
const { body } = await supertest | ||
.put( | ||
`/api/beats/agent/${beatId}` | ||
) | ||
.set('kbn-xsrf', 'xxx') | ||
.send(beat) | ||
.expect(400); | ||
|
||
expect(body.message).to.be('Beat has not been verified'); | ||
|
||
const beatInEs = await es.get({ | ||
index: ES_INDEX_NAME, | ||
type: ES_TYPE_NAME, | ||
id: `beat:${beatId}` | ||
}); | ||
|
||
expect(beatInEs._source.beat.id).to.be(beatId); | ||
expect(beatInEs._source.beat.type).to.not.be(beat.type); | ||
expect(beatInEs._source.beat.host_name).to.not.be(beat.host_name); | ||
expect(beatInEs._source.beat.version).to.not.be(beat.version); | ||
expect(beatInEs._source.beat.ephemeral_id).to.not.be(beat.ephemeral_id); | ||
}); | ||
|
||
it('should return an error for a non-existent beat', async () => { | ||
const beatId = chance.word(); | ||
const { body } = await supertest | ||
.put( | ||
`/api/beats/agent/${beatId}` | ||
) | ||
.set('kbn-xsrf', 'xxx') | ||
.send(beat) | ||
.expect(404); | ||
|
||
expect(body.message).to.be('Beat not found'); | ||
}); | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -54,6 +54,9 @@ | |
"type": { | ||
"type": "keyword" | ||
}, | ||
"version": { | ||
"type": "keyword" | ||
}, | ||
"host_ip": { | ||
"type": "ip" | ||
}, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@exekias In this PR I'm implementing the "Update Beat" API (
PUT /api/beats/agent/{beat UUID}
). That API can take any of the following Beat information in its request body:type
: filebeat, metricbeat, etc.version
: beat versionhost_name
ephemeral_id
local_configuration_yml
metadata
: arbitrary extra metadata, if we want anyWhich of these above properties do you expect to send at enrollment time as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ones from this list we should be sending for sure are type & hostname. I'm divided on version, but probably it makes sense to include it during enrolment too (also here, as it may change)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, thanks. I'll add
version
as a required field to the payload for the enrollment API (it already takestype
andhost_name
as required fields).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added in 5e63721.