diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 1dc98f9bc89476..5bf7c910168c0a 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -50,6 +50,7 @@ export const AGENT_API_ROUTES = { EVENTS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/events`, CHECKIN_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/checkin`, ACKS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/acks`, + ACTIONS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/actions`, ENROLL_PATTERN: `${FLEET_API_ROOT}/agents/enroll`, UNENROLL_PATTERN: `${FLEET_API_ROOT}/agents/unenroll`, STATUS_PATTERN: `${FLEET_API_ROOT}/agent-status`, diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index 179cc3fc9eb553..aa5729a101e111 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -14,14 +14,17 @@ export type AgentType = export type AgentStatus = 'offline' | 'error' | 'online' | 'inactive' | 'warning'; -export interface AgentAction extends SavedObjectAttributes { +export interface NewAgentAction { type: 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE'; - id: string; - created_at: string; data?: string; sent_at?: string; } +export type AgentAction = NewAgentAction & { + id: string; + created_at: string; +} & SavedObjectAttributes; + export interface AgentEvent { type: 'STATE' | 'ERROR' | 'ACTION_RESULT' | 'ACTION'; subtype: // State diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts index 7bbaf42422bb25..21ab41740ce3e2 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType } from '../models'; +import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType, NewAgentAction } from '../models'; export interface GetAgentsRequest { query: { @@ -81,6 +81,20 @@ export interface PostAgentAcksResponse { success: boolean; } +export interface PostNewAgentActionRequest { + body: { + action: NewAgentAction; + }; + params: { + agentId: string; + }; +} + +export interface PostNewAgentActionResponse { + success: boolean; + item: AgentAction; +} + export interface PostAgentUnenrollRequest { body: { kuery: string } | { ids: string[] }; } diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts new file mode 100644 index 00000000000000..a20ba4a880537d --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { NewAgentActionSchema } from '../../types/models'; +import { + KibanaResponseFactory, + RequestHandlerContext, + SavedObjectsClientContract, +} from 'kibana/server'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock'; +import { httpServerMock } from '../../../../../../src/core/server/http/http_server.mocks'; +import { ActionsService } from '../../services/agents'; +import { AgentAction } from '../../../common/types/models'; +import { postNewAgentActionHandlerBuilder } from './actions_handlers'; +import { + PostNewAgentActionRequest, + PostNewAgentActionResponse, +} from '../../../common/types/rest_spec'; + +describe('test actions handlers schema', () => { + it('validate that new agent actions schema is valid', async () => { + expect( + NewAgentActionSchema.validate({ + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }) + ).toBeTruthy(); + }); + + it('validate that new agent actions schema is invalid when required properties are not provided', async () => { + expect(() => { + NewAgentActionSchema.validate({ + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }); + }).toThrowError(); + }); +}); + +describe('test actions handlers', () => { + let mockResponse: jest.Mocked; + let mockSavedObjectsClient: jest.Mocked; + + beforeEach(() => { + mockSavedObjectsClient = savedObjectsClientMock.create(); + mockResponse = httpServerMock.createResponseFactory(); + }); + + it('should succeed on valid new agent action', async () => { + const postNewAgentActionRequest: PostNewAgentActionRequest = { + body: { + action: { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }, + }, + params: { + agentId: 'id', + }, + }; + + const mockRequest = httpServerMock.createKibanaRequest(postNewAgentActionRequest); + + const agentAction = ({ + type: 'CONFIG_CHANGE', + id: 'action1', + sent_at: '2020-03-14T19:45:02.620Z', + timestamp: '2019-01-04T14:32:03.36764-05:00', + created_at: '2020-03-14T19:45:02.620Z', + } as unknown) as AgentAction; + + const actionsService: ActionsService = { + getAgent: jest.fn().mockReturnValueOnce({ + id: 'agent', + }), + updateAgentActions: jest.fn().mockReturnValueOnce(agentAction), + } as jest.Mocked; + + const postNewAgentActionHandler = postNewAgentActionHandlerBuilder(actionsService); + await postNewAgentActionHandler( + ({ + core: { + savedObjects: { + client: mockSavedObjectsClient, + }, + }, + } as unknown) as RequestHandlerContext, + mockRequest, + mockResponse + ); + + const expectedAgentActionResponse = (mockResponse.ok.mock.calls[0][0] + ?.body as unknown) as PostNewAgentActionResponse; + + expect(expectedAgentActionResponse.item).toEqual(agentAction); + expect(expectedAgentActionResponse.success).toEqual(true); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts new file mode 100644 index 00000000000000..2b9c2308035932 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts @@ -0,0 +1,57 @@ +/* + * 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. + */ + +// handlers that handle agent actions request + +import { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { PostNewAgentActionRequestSchema } from '../../types/rest_spec'; +import { ActionsService } from '../../services/agents'; +import { NewAgentAction } from '../../../common/types/models'; +import { PostNewAgentActionResponse } from '../../../common/types/rest_spec'; + +export const postNewAgentActionHandlerBuilder = function( + actionsService: ActionsService +): RequestHandler< + TypeOf, + undefined, + TypeOf +> { + return async (context, request, response) => { + try { + const soClient = context.core.savedObjects.client; + + const agent = await actionsService.getAgent(soClient, request.params.agentId); + + const newAgentAction = request.body.action as NewAgentAction; + + const savedAgentAction = await actionsService.updateAgentActions( + soClient, + agent, + newAgentAction + ); + + const body: PostNewAgentActionResponse = { + success: true, + item: savedAgentAction, + }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom) { + return response.customError({ + statusCode: e.output.statusCode, + body: { message: e.message }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } + }; +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts index 414d2d79e90671..d4610270178429 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -22,6 +22,7 @@ import { PostAgentAcksRequestSchema, PostAgentUnenrollRequestSchema, GetAgentStatusRequestSchema, + PostNewAgentActionRequestSchema, } from '../../types'; import { getAgentsHandler, @@ -37,6 +38,7 @@ import { } from './handlers'; import { postAgentAcksHandlerBuilder } from './acks_handlers'; import * as AgentService from '../../services/agents'; +import { postNewAgentActionHandlerBuilder } from './actions_handlers'; export const registerRoutes = (router: IRouter) => { // Get one @@ -111,6 +113,19 @@ export const registerRoutes = (router: IRouter) => { }) ); + // Agent actions + router.post( + { + path: AGENT_API_ROUTES.ACTIONS_PATTERN, + validate: PostNewAgentActionRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postNewAgentActionHandlerBuilder({ + getAgent: AgentService.getAgent, + updateAgentActions: AgentService.updateAgentActions, + }) + ); + router.post( { path: AGENT_API_ROUTES.UNENROLL_PATTERN, diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts new file mode 100644 index 00000000000000..b500aeb825fec7 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { createAgentAction, updateAgentActions } from './actions'; +import { Agent, AgentAction, NewAgentAction } from '../../../common/types/models'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock'; +import { AGENT_TYPE_PERMANENT } from '../../../common/constants'; + +interface UpdatedActions { + actions: AgentAction[]; +} + +describe('test agent actions services', () => { + it('should update agent current actions with new action', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + + const newAgentAction: NewAgentAction = { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }; + + await updateAgentActions( + mockSavedObjectsClient, + ({ + id: 'id', + type: AGENT_TYPE_PERMANENT, + actions: [ + { + type: 'CONFIG_CHANGE', + id: 'action1', + sent_at: '2020-03-14T19:45:02.620Z', + timestamp: '2019-01-04T14:32:03.36764-05:00', + created_at: '2020-03-14T19:45:02.620Z', + }, + ], + } as unknown) as Agent, + newAgentAction + ); + + const updatedAgentActions = (mockSavedObjectsClient.update.mock + .calls[0][2] as unknown) as UpdatedActions; + + expect(updatedAgentActions.actions.length).toEqual(2); + const actualAgentAction = updatedAgentActions.actions.find(action => action?.data === 'data'); + expect(actualAgentAction?.type).toEqual(newAgentAction.type); + expect(actualAgentAction?.data).toEqual(newAgentAction.data); + expect(actualAgentAction?.sent_at).toEqual(newAgentAction.sent_at); + }); + + it('should create agent action from new agent action model', async () => { + const newAgentAction: NewAgentAction = { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }; + const now = new Date(); + const agentAction = createAgentAction(now, newAgentAction); + + expect(agentAction.type).toEqual(newAgentAction.type); + expect(agentAction.data).toEqual(newAgentAction.data); + expect(agentAction.sent_at).toEqual(newAgentAction.sent_at); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts new file mode 100644 index 00000000000000..2f8ed9f504453a --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts @@ -0,0 +1,50 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/server'; +import uuid from 'uuid'; +import { + Agent, + AgentAction, + AgentSOAttributes, + NewAgentAction, +} from '../../../common/types/models'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../../common/constants'; + +export async function updateAgentActions( + soClient: SavedObjectsClientContract, + agent: Agent, + newAgentAction: NewAgentAction +): Promise { + const agentAction = createAgentAction(new Date(), newAgentAction); + + agent.actions.push(agentAction); + + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, { + actions: agent.actions, + }); + + return agentAction; +} + +export function createAgentAction(createdAt: Date, newAgentAction: NewAgentAction): AgentAction { + const agentAction = { + id: uuid.v4(), + created_at: createdAt.toISOString(), + }; + + return Object.assign(agentAction, newAgentAction); +} + +export interface ActionsService { + getAgent: (soClient: SavedObjectsClientContract, agentId: string) => Promise; + + updateAgentActions: ( + soClient: SavedObjectsClientContract, + agent: Agent, + newAgentAction: NewAgentAction + ) => Promise; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/index.ts b/x-pack/plugins/ingest_manager/server/services/agents/index.ts index 477f081d1900b1..c95c9ecc2a1d88 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/index.ts @@ -12,3 +12,4 @@ export * from './unenroll'; export * from './status'; export * from './crud'; export * from './update'; +export * from './actions'; diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts index e0d252faaaf87c..f70b3cf0ed092b 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts @@ -52,3 +52,14 @@ export const AckEventSchema = schema.object({ export const AgentEventSchema = schema.object({ ...AgentEventBase, }); + +export const NewAgentActionSchema = schema.object({ + type: schema.oneOf([ + schema.literal('CONFIG_CHANGE'), + schema.literal('DATA_DUMP'), + schema.literal('RESUME'), + schema.literal('PAUSE'), + ]), + data: schema.maybe(schema.string()), + sent_at: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts index 9fe84c12521add..f94c02ccee40bc 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { AckEventSchema, AgentEventSchema, AgentTypeSchema } from '../models'; +import { AckEventSchema, AgentEventSchema, AgentTypeSchema, NewAgentActionSchema } from '../models'; export const GetAgentsRequestSchema = { query: schema.object({ @@ -52,6 +52,15 @@ export const PostAgentAcksRequestSchema = { }), }; +export const PostNewAgentActionRequestSchema = { + body: schema.object({ + action: NewAgentActionSchema, + }), + params: schema.object({ + agentId: schema.string(), + }), +}; + export const PostAgentUnenrollRequestSchema = { body: schema.oneOf([ schema.object({ diff --git a/x-pack/test/api_integration/apis/fleet/agents/actions.ts b/x-pack/test/api_integration/apis/fleet/agents/actions.ts new file mode 100644 index 00000000000000..f27b932cff5cba --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/agents/actions.ts @@ -0,0 +1,86 @@ +/* + * 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 '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function(providerContext: FtrProviderContext) { + const { getService } = providerContext; + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('fleet_agents_actions', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should return a 200 if this a valid actions request', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'CONFIG_CHANGE', + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(200); + + expect(apiResponse.success).to.be(true); + expect(apiResponse.item.data).to.be('action_data'); + expect(apiResponse.item.sent_at).to.be('2020-03-18T19:45:02.620Z'); + + const { body: agentResponse } = await supertest + .get(`/api/ingest_manager/fleet/agents/agent1`) + .set('kbn-xsrf', 'xx') + .expect(200); + + const updatedAction = agentResponse.item.actions.find( + (itemAction: Record) => itemAction?.data === 'action_data' + ); + + expect(updatedAction.type).to.be('CONFIG_CHANGE'); + expect(updatedAction.data).to.be('action_data'); + expect(updatedAction.sent_at).to.be('2020-03-18T19:45:02.620Z'); + }); + + it('should return a 400 when request does not have type information', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(400); + expect(apiResponse.message).to.eql( + '[request body.action.type]: expected at least one defined value but got [undefined]' + ); + }); + + it('should return a 404 when agent does not exist', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent100/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'CONFIG_CHANGE', + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(404); + expect(apiResponse.message).to.eql('Saved object [agents/agent100] not found'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/api_integration/apis/fleet/index.js index 69d30291f030bf..547bbb8c7c6ee9 100644 --- a/x-pack/test/api_integration/apis/fleet/index.js +++ b/x-pack/test/api_integration/apis/fleet/index.js @@ -15,5 +15,6 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./agents/acks')); loadTestFile(require.resolve('./enrollment_api_keys/crud')); loadTestFile(require.resolve('./install')); + loadTestFile(require.resolve('./agents/actions')); }); }