[Ingest]EMT-248: add post action request handler and resources (#60581)

[Ingest]EMT-248: add resource to allow to post new agent action.
This commit is contained in:
nnamdifrankie 2020-03-19 18:15:56 -04:00 committed by GitHub
parent 3acbbcd2b0
commit d1aaa4430a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 423 additions and 5 deletions

View file

@ -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`,

View file

@ -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

View file

@ -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[] };
}

View file

@ -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<KibanaResponseFactory>;
let mockSavedObjectsClient: jest.Mocked<SavedObjectsClientContract>;
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<ActionsService>;
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);
});
});

View file

@ -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<typeof PostNewAgentActionRequestSchema.params>,
undefined,
TypeOf<typeof PostNewAgentActionRequestSchema.body>
> {
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 },
});
}
};
};

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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<AgentAction> {
const agentAction = createAgentAction(new Date(), newAgentAction);
agent.actions.push(agentAction);
await soClient.update<AgentSOAttributes>(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<Agent>;
updateAgentActions: (
soClient: SavedObjectsClientContract,
agent: Agent,
newAgentAction: NewAgentAction
) => Promise<AgentAction>;
}

View file

@ -12,3 +12,4 @@ export * from './unenroll';
export * from './status';
export * from './crud';
export * from './update';
export * from './actions';

View file

@ -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()),
});

View file

@ -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({

View file

@ -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<string, string>) => 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');
});
});
}

View file

@ -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'));
});
}