Skip to content
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

EMT-248: implement ack resource to accept event payload to acknowledge agent actions #60218

Merged
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,18 @@ export interface PostAgentEnrollResponse {

export interface PostAgentAcksRequest {
body: {
action_ids: string[];
events: AgentEvent[];
};
params: {
agentId: string;
};
}

export interface PostAgentAcksResponse {
action: string;
success: boolean;
}

export interface PostAgentUnenrollRequest {
body: { kuery: string } | { ids: string[] };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* 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 { postAgentAcksHandlerBuilder } from './acks_handlers';
import {
KibanaResponseFactory,
RequestHandlerContext,
SavedObjectsClientContract,
} from 'kibana/server';
import { httpServerMock, savedObjectsClientMock } from '../../../../../../src/core/server/mocks';
import { PostAgentAcksResponse } from '../../../common/types/rest_spec';
import { AckEventSchema } from '../../types/models';
import { AcksService } from '../../services/agents';

describe('test acks schema', () => {
it('validate that ack event schema expect action id', async () => {
expect(() =>
AckEventSchema.validate({
type: 'ACTION_RESULT',
subtype: 'CONFIG',
timestamp: '2019-01-04T14:32:03.36764-05:00',
agent_id: 'agent',
message: 'hello',
payload: 'payload',
})
).toThrow(Error);

expect(
AckEventSchema.validate({
type: 'ACTION_RESULT',
subtype: 'CONFIG',
timestamp: '2019-01-04T14:32:03.36764-05:00',
agent_id: 'agent',
action_id: 'actionId',
message: 'hello',
payload: 'payload',
})
).toBeTruthy();
});
});

describe('test acks handlers', () => {
let mockResponse: jest.Mocked<KibanaResponseFactory>;
let mockSavedObjectsClient: jest.Mocked<SavedObjectsClientContract>;

beforeEach(() => {
mockSavedObjectsClient = savedObjectsClientMock.create();
mockResponse = httpServerMock.createResponseFactory();
});

it('should succeed on valid agent event', async () => {
const mockRequest = httpServerMock.createKibanaRequest({
headers: {
authorization: 'ApiKey TmVqTDBIQUJsRkw1em52R1ZIUF86NS1NaTItdHFUTHFHbThmQW1Fb0ljUQ==',
},
body: {
events: [
{
type: 'ACTION_RESULT',
subtype: 'CONFIG',
timestamp: '2019-01-04T14:32:03.36764-05:00',
action_id: 'action1',
agent_id: 'agent',
message: 'message',
},
],
},
});

const ackService: AcksService = {
acknowledgeAgentActions: jest.fn().mockReturnValueOnce([
{
type: 'CONFIG_CHANGE',
id: 'action1',
},
]),
getAgentByAccessAPIKeyId: jest.fn().mockReturnValueOnce({
id: 'agent',
}),
getSavedObjectsClientContract: jest.fn().mockReturnValueOnce(mockSavedObjectsClient),
saveAgentEvents: jest.fn(),
} as jest.Mocked<AcksService>;

const postAgentAcksHandler = postAgentAcksHandlerBuilder(ackService);
await postAgentAcksHandler(({} as unknown) as RequestHandlerContext, mockRequest, mockResponse);
expect(mockResponse.ok.mock.calls[0][0]?.body as PostAgentAcksResponse).toEqual({
action: 'acks',
success: true,
});
});
});
69 changes: 69 additions & 0 deletions x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* 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 events from agents in response to actions received

import { RequestHandler } from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
import { PostAgentAcksRequestSchema } from '../../types/rest_spec';
import * as APIKeyService from '../../services/api_keys';
import { AcksService } from '../../services/agents';
import { AgentEvent } from '../../../common/types/models';
import { PostAgentAcksResponse } from '../../../common/types/rest_spec';

export const postAgentAcksHandlerBuilder = function(
ackService: AcksService
): RequestHandler<
TypeOf<typeof PostAgentAcksRequestSchema.params>,
undefined,
TypeOf<typeof PostAgentAcksRequestSchema.body>
> {
return async (context, request, response) => {
try {
const soClient = ackService.getSavedObjectsClientContract(request);
const res = APIKeyService.parseApiKey(request.headers);
const agent = await ackService.getAgentByAccessAPIKeyId(soClient, res.apiKeyId as string);
const agentEvents = request.body.events as AgentEvent[];

// validate that all events are for the authorized agent obtained from the api key
const notAuthorizedAgentEvent = agentEvents.filter(
agentEvent => agentEvent.agent_id !== agent.id
);

if (notAuthorizedAgentEvent && notAuthorizedAgentEvent.length > 0) {
return response.badRequest({
body:
'agent events contains events with different agent id from currently authorized agent',
});
}

const agentActions = await ackService.acknowledgeAgentActions(soClient, agent, agentEvents);

if (agentActions.length > 0) {
await ackService.saveAgentEvents(soClient, agentEvents);
}

const body: PostAgentAcksResponse = {
action: 'acks',
success: true,
};

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 },
});
}
};
};
36 changes: 1 addition & 35 deletions x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,14 @@ import {
GetOneAgentEventsRequestSchema,
PostAgentCheckinRequestSchema,
PostAgentEnrollRequestSchema,
PostAgentAcksRequestSchema,
PostAgentUnenrollRequestSchema,
GetAgentStatusRequestSchema,
} from '../../types';
import * as AgentService from '../../services/agents';
import * as APIKeyService from '../../services/api_keys';
import { appContextService } from '../../services/app_context';

function getInternalUserSOClient(request: KibanaRequest) {
export function getInternalUserSOClient(request: KibanaRequest) {
// soClient as kibana internal users, be carefull on how you use it, security is not enabled
return appContextService.getSavedObjects().getScopedClient(request, {
excludedWrappers: ['security'],
Expand Down Expand Up @@ -210,39 +209,6 @@ export const postAgentCheckinHandler: RequestHandler<
}
};

export const postAgentAcksHandler: RequestHandler<
TypeOf<typeof PostAgentAcksRequestSchema.params>,
undefined,
TypeOf<typeof PostAgentAcksRequestSchema.body>
> = async (context, request, response) => {
try {
const soClient = getInternalUserSOClient(request);
const res = APIKeyService.parseApiKey(request.headers);
const agent = await AgentService.getAgentByAccessAPIKeyId(soClient, res.apiKeyId as string);

await AgentService.acknowledgeAgentActions(soClient, agent, request.body.action_ids);

const body = {
action: 'acks',
success: true,
};

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

export const postAgentEnrollHandler: RequestHandler<
undefined,
undefined,
Expand Down
11 changes: 9 additions & 2 deletions x-pack/plugins/ingest_manager/server/routes/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ import {
getAgentEventsHandler,
postAgentCheckinHandler,
postAgentEnrollHandler,
postAgentAcksHandler,
postAgentsUnenrollHandler,
getAgentStatusForConfigHandler,
getInternalUserSOClient,
} from './handlers';
import { postAgentAcksHandlerBuilder } from './acks_handlers';
import * as AgentService from '../../services/agents';

export const registerRoutes = (router: IRouter) => {
// Get one
Expand Down Expand Up @@ -101,7 +103,12 @@ export const registerRoutes = (router: IRouter) => {
validate: PostAgentAcksRequestSchema,
options: { tags: [] },
},
postAgentAcksHandler
postAgentAcksHandlerBuilder({
acknowledgeAgentActions: AgentService.acknowledgeAgentActions,
getAgentByAccessAPIKeyId: AgentService.getAgentByAccessAPIKeyId,
getSavedObjectsClientContract: getInternalUserSOClient,
saveAgentEvents: AgentService.saveAgentEvents,
})
);

router.post(
Expand Down
118 changes: 118 additions & 0 deletions x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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 { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock';
import { Agent, AgentAction, AgentEvent } from '../../../common/types/models';
import { AGENT_TYPE_PERMANENT } from '../../../common/constants';
import { acknowledgeAgentActions } from './acks';
import { isBoom } from 'boom';

describe('test agent acks services', () => {
it('should succeed on valid and matched actions', async () => {
const mockSavedObjectsClient = savedObjectsClientMock.create();
const agentActions = await acknowledgeAgentActions(
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,
[
{
type: 'ACTION_RESULT',
subtype: 'CONFIG',
timestamp: '2019-01-04T14:32:03.36764-05:00',
action_id: 'action1',
agent_id: 'id',
} as AgentEvent,
]
);
expect(agentActions).toEqual([
({
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,
]);
});

it('should fail for actions that cannot be found on agent actions list', async () => {
const mockSavedObjectsClient = savedObjectsClientMock.create();
try {
await acknowledgeAgentActions(
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,
[
({
type: 'ACTION_RESULT',
subtype: 'CONFIG',
timestamp: '2019-01-04T14:32:03.36764-05:00',
action_id: 'action2',
agent_id: 'id',
} as unknown) as AgentEvent,
]
);
expect(true).toBeFalsy();
} catch (e) {
expect(isBoom(e)).toBeTruthy();
}
});

it('should fail for events that have types not in the allowed acknowledgement type list', async () => {
const mockSavedObjectsClient = savedObjectsClientMock.create();
try {
await acknowledgeAgentActions(
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,
[
({
type: 'ACTION',
subtype: 'FAILED',
timestamp: '2019-01-04T14:32:03.36764-05:00',
action_id: 'action1',
agent_id: 'id',
} as unknown) as AgentEvent,
]
);
expect(true).toBeFalsy();
} catch (e) {
expect(isBoom(e)).toBeTruthy();
}
});
});
Loading