[7.2] [BeatsCM] Move API to new return format (#31660) (#38040)

* [BeatsCM] Move API to new return format (#31660)

* add and assign types

* move error management to the lib vs each route. More DRY and easier to test

* move more endpoints to the new format

* fix result for not-yet-pages beats list

* refine more endpoints

* move more routes to new format

* UI now propperly connected to the API

* uodate tests and documenting testing in readme

# Conflicts:
#	x-pack/plugins/beats_management/server/rest_api/beats/configuration.ts

* fix linting errors
This commit is contained in:
Matt Apperson 2019-06-05 19:38:52 -04:00 committed by GitHub
parent b5e8e9fae9
commit 7170757d49
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 726 additions and 535 deletions

View file

@ -0,0 +1,116 @@
/*
* 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.
*/
export interface BaseReturnType {
error?: {
message: string;
code?: number;
};
success: boolean;
}
export interface ReturnTypeCreate<T> extends BaseReturnType {
item: T;
action: 'created';
}
export interface ReturnTypeUpdate<T> extends BaseReturnType {
item: T;
action: 'updated';
}
export interface ReturnTypeBulkCreate<T> extends BaseReturnType {
results: Array<{
item: T;
success: boolean;
action: 'created';
error?: {
message: string;
code?: number;
};
}>;
}
// delete
export interface ReturnTypeDelete extends BaseReturnType {
action: 'deleted';
}
export interface ReturnTypeBulkDelete extends BaseReturnType {
results: Array<{
success: boolean;
action: 'deleted';
error?: {
message: string;
code?: number;
};
}>;
}
// upsert
export interface ReturnTypeUpsert<T> extends BaseReturnType {
item: T;
action: 'created' | 'updated';
}
// upsert bulk
export interface ReturnTypeBulkUpsert extends BaseReturnType {
results: Array<{
success: boolean;
action: 'created' | 'updated';
error?: {
message: string;
code?: number;
};
}>;
}
// list
export interface ReturnTypeList<T> extends BaseReturnType {
list: T[];
page: number;
total: number;
}
// get
export interface ReturnTypeGet<T> extends BaseReturnType {
item: T;
}
export interface ReturnTypeBulkGet<T> extends BaseReturnType {
items: T[];
}
// action -- e.g. validate config block. Like ES simulate endpoint
export interface ReturnTypeAction extends BaseReturnType {
result: {
[key: string]: any;
};
}
// e.g.
// {
// result: {
// username: { valid: true },
// password: { valid: false, error: 'something' },
// hosts: [
// { valid: false }, { valid: true },
// ]
// }
// }
// bulk action -- e.g. assign tags to beats
export interface ReturnTypeBulkAction extends BaseReturnType {
results?: Array<{
success: boolean;
result?: {
[key: string]: any;
};
error?: {
message: string;
code?: number;
};
}>;
}

View file

@ -15,7 +15,7 @@ import { ConfigurationBlock } from '../../common/domain_types';
interface ComponentProps {
configs: {
error?: string | undefined;
blocks: ConfigurationBlock[];
list: ConfigurationBlock[];
page: number;
total: number;
};
@ -30,7 +30,7 @@ const pagination = {
const ConfigListUi: React.SFC<ComponentProps> = props => (
<EuiBasicTable
items={props.configs.blocks || []}
items={props.configs.list || []}
itemId="id"
pagination={{
...pagination,

View file

@ -34,7 +34,7 @@ interface TagEditProps {
tag: BeatTag;
configuration_blocks: {
error?: string | undefined;
blocks: ConfigurationBlock[];
list: ConfigurationBlock[];
page: number;
total: number;
};

View file

@ -5,14 +5,15 @@
*/
import { CMBeat } from '../../../../common/domain_types';
import { ReturnTypeBulkAction } from '../../../../common/return_types';
export interface CMBeatsAdapter {
get(id: string): Promise<CMBeat | null>;
update(id: string, beatData: Partial<CMBeat>): Promise<boolean>;
getBeatsWithTag(tagId: string): Promise<CMBeat[]>;
getAll(ESQuery?: any): Promise<CMBeat[]>;
removeTagsFromBeats(removals: BeatsTagAssignment[]): Promise<BeatsRemovalReturn[]>;
assignTagsToBeats(assignments: BeatsTagAssignment[]): Promise<CMAssignmentReturn[]>;
removeTagsFromBeats(removals: BeatsTagAssignment[]): Promise<ReturnTypeBulkAction['results']>;
assignTagsToBeats(assignments: BeatsTagAssignment[]): Promise<ReturnTypeBulkAction['results']>;
getBeatWithToken(enrollmentToken: string): Promise<CMBeat | null>;
}

View file

@ -5,14 +5,9 @@
*/
import { omit } from 'lodash';
import { CMBeat } from '../../../../common/domain_types';
import {
BeatsRemovalReturn,
BeatsTagAssignment,
CMAssignmentReturn,
CMBeatsAdapter,
} from './adapter_types';
import { ReturnTypeBulkAction } from '../../../../common/return_types';
import { BeatsTagAssignment, CMBeatsAdapter } from './adapter_types';
export class MemoryBeatsAdapter implements CMBeatsAdapter {
private beatsDB: CMBeat[];
@ -46,7 +41,9 @@ export class MemoryBeatsAdapter implements CMBeatsAdapter {
public async getBeatWithToken(enrollmentToken: string): Promise<CMBeat | null> {
return this.beatsDB.map<CMBeat>((beat: any) => omit(beat, ['access_token']))[0];
}
public async removeTagsFromBeats(removals: BeatsTagAssignment[]): Promise<BeatsRemovalReturn[]> {
public async removeTagsFromBeats(
removals: BeatsTagAssignment[]
): Promise<ReturnTypeBulkAction['results']> {
const beatIds = removals.map(r => r.beatId);
const response = this.beatsDB
@ -76,7 +73,9 @@ export class MemoryBeatsAdapter implements CMBeatsAdapter {
}));
}
public async assignTagsToBeats(assignments: BeatsTagAssignment[]): Promise<CMAssignmentReturn[]> {
public async assignTagsToBeats(
assignments: BeatsTagAssignment[]
): Promise<ReturnTypeBulkAction['results']> {
const beatIds = assignments.map(r => r.beatId);
this.beatsDB

View file

@ -5,19 +5,20 @@
*/
import { CMBeat } from '../../../../common/domain_types';
import { RestAPIAdapter } from '../rest_api/adapter_types';
import {
BeatsRemovalReturn,
BeatsTagAssignment,
CMAssignmentReturn,
CMBeatsAdapter,
} from './adapter_types';
ReturnTypeBulkAction,
ReturnTypeGet,
ReturnTypeList,
ReturnTypeUpdate,
} from '../../../../common/return_types';
import { RestAPIAdapter } from '../rest_api/adapter_types';
import { BeatsTagAssignment, CMBeatsAdapter } from './adapter_types';
export class RestBeatsAdapter implements CMBeatsAdapter {
constructor(private readonly REST: RestAPIAdapter) {}
public async get(id: string): Promise<CMBeat | null> {
try {
return await this.REST.get<CMBeat>(`/api/beats/agent/${id}`);
return (await this.REST.get<ReturnTypeGet<CMBeat>>(`/api/beats/agent/${id}`)).item;
} catch (e) {
return null;
}
@ -25,7 +26,9 @@ export class RestBeatsAdapter implements CMBeatsAdapter {
public async getBeatWithToken(enrollmentToken: string): Promise<CMBeat | null> {
try {
return await this.REST.get<CMBeat>(`/api/beats/agent/unknown/${enrollmentToken}`);
return (await this.REST.get<ReturnTypeGet<CMBeat>>(
`/api/beats/agent/unknown/${enrollmentToken}`
)).item;
} catch (e) {
return null;
}
@ -33,7 +36,8 @@ export class RestBeatsAdapter implements CMBeatsAdapter {
public async getAll(ESQuery?: string): Promise<CMBeat[]> {
try {
return (await this.REST.get<{ beats: CMBeat[] }>('/api/beats/agents/all', { ESQuery })).beats;
return (await this.REST.get<ReturnTypeList<CMBeat>>('/api/beats/agents/all', { ESQuery }))
.list;
} catch (e) {
return [];
}
@ -41,32 +45,30 @@ export class RestBeatsAdapter implements CMBeatsAdapter {
public async getBeatsWithTag(tagId: string): Promise<CMBeat[]> {
try {
return (await this.REST.get<{ beats: CMBeat[] }>(`/api/beats/agents/tag/${tagId}`)).beats;
return (await this.REST.get<ReturnTypeList<CMBeat>>(`/api/beats/agents/tag/${tagId}`)).list;
} catch (e) {
return [];
}
}
public async update(id: string, beatData: Partial<CMBeat>): Promise<boolean> {
await this.REST.put<{ success: true }>(`/api/beats/agent/${id}`, beatData);
await this.REST.put<ReturnTypeUpdate<CMBeat>>(`/api/beats/agent/${id}`, beatData);
return true;
}
public async removeTagsFromBeats(removals: BeatsTagAssignment[]): Promise<BeatsRemovalReturn[]> {
return (await this.REST.post<{ removals: BeatsRemovalReturn[] }>(
`/api/beats/agents_tags/removals`,
{
removals,
}
)).removals;
public async removeTagsFromBeats(
removals: BeatsTagAssignment[]
): Promise<ReturnTypeBulkAction['results']> {
return (await this.REST.post<ReturnTypeBulkAction>(`/api/beats/agents_tags/removals`, {
removals,
})).results;
}
public async assignTagsToBeats(assignments: BeatsTagAssignment[]): Promise<CMAssignmentReturn[]> {
return (await this.REST.post<{ assignments: CMAssignmentReturn[] }>(
`/api/beats/agents_tags/assignments`,
{
assignments,
}
)).assignments;
public async assignTagsToBeats(
assignments: BeatsTagAssignment[]
): Promise<ReturnTypeBulkAction['results']> {
return (await this.REST.post<ReturnTypeBulkAction>(`/api/beats/agents_tags/assignments`, {
assignments,
})).results;
}
}

View file

@ -4,19 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ConfigurationBlock } from '../../../../common/domain_types';
import { ReturnTypeBulkUpsert, ReturnTypeList } from '../../../../common/return_types';
export interface FrontendConfigBlocksAdapter {
upsert(
blocks: ConfigurationBlock[]
): Promise<Array<{ success?: boolean; blockID?: string; error?: string }>>;
getForTags(
tagIds: string[],
page: number
): Promise<{
error?: string;
blocks: ConfigurationBlock[];
page: number;
total: number;
}>;
upsert(blocks: ConfigurationBlock[]): Promise<ReturnTypeBulkUpsert>;
getForTags(tagIds: string[], page: number): Promise<ReturnTypeList<ConfigurationBlock>>;
delete(id: string): Promise<boolean>;
}

View file

@ -5,30 +5,26 @@
*/
import { ConfigurationBlock } from '../../../../common/domain_types';
import { ReturnTypeBulkUpsert, ReturnTypeList } from '../../../../common/return_types';
import { FrontendConfigBlocksAdapter } from './adapter_types';
export class MemoryConfigBlocksAdapter implements FrontendConfigBlocksAdapter {
constructor(private db: ConfigurationBlock[]) {}
public async upsert(blocks: ConfigurationBlock[]) {
public async upsert(blocks: ConfigurationBlock[]): Promise<ReturnTypeBulkUpsert> {
this.db = this.db.concat(blocks);
return blocks.map(() => ({
success: true,
blockID: Math.random()
.toString(36)
.substring(7),
}));
}
public async getForTags(
tagIds: string[]
): Promise<{
error?: string;
blocks: ConfigurationBlock[];
page: number;
total: number;
}> {
return {
blocks: this.db.filter(block => tagIds.includes(block.tag)),
success: true,
results: blocks.map(() => ({
success: true,
action: 'created',
})),
} as ReturnTypeBulkUpsert;
}
public async getForTags(tagIds: string[]): Promise<ReturnTypeList<ConfigurationBlock>> {
return {
success: true,
list: this.db.filter(block => tagIds.includes(block.tag)),
page: 0,
total: this.db.filter(block => tagIds.includes(block.tag)).length,
};

View file

@ -5,6 +5,11 @@
*/
import { ConfigurationBlock } from '../../../../common/domain_types';
import {
ReturnTypeBulkDelete,
ReturnTypeBulkUpsert,
ReturnTypeList,
} from '../../../../common/return_types';
import { RestAPIAdapter } from '../rest_api/adapter_types';
import { FrontendConfigBlocksAdapter } from './adapter_types';
@ -12,29 +17,19 @@ export class RestConfigBlocksAdapter implements FrontendConfigBlocksAdapter {
constructor(private readonly REST: RestAPIAdapter) {}
public async upsert(blocks: ConfigurationBlock[]) {
const result = await this.REST.put<
Array<{ success?: boolean; blockID?: string; error?: string }>
>(`/api/beats/configurations`, blocks);
const result = await this.REST.put<ReturnTypeBulkUpsert>(`/api/beats/configurations`, blocks);
return result;
}
public async getForTags(
tagIds: string[],
page: number
): Promise<{
error?: string;
blocks: ConfigurationBlock[];
page: number;
total: number;
}> {
return await this.REST.get<{
error?: string;
blocks: ConfigurationBlock[];
page: number;
total: number;
}>(`/api/beats/configurations/${tagIds.join(',')}/${page}`);
): Promise<ReturnTypeList<ConfigurationBlock>> {
return await this.REST.get<ReturnTypeList<ConfigurationBlock>>(
`/api/beats/configurations/${tagIds.join(',')}/${page}`
);
}
public async delete(id: string): Promise<boolean> {
return (await this.REST.delete<{ success: boolean }>(`/api/beats/configurations/${id}`))
return (await this.REST.delete<ReturnTypeBulkDelete>(`/api/beats/configurations/${id}`))
.success;
}
}

View file

@ -6,6 +6,12 @@
import { uniq } from 'lodash';
import { BeatTag, CMBeat } from '../../../../common/domain_types';
import {
ReturnTypeBulkDelete,
ReturnTypeBulkGet,
ReturnTypeList,
ReturnTypeUpsert,
} from '../../../../common/return_types';
import { RestAPIAdapter } from '../rest_api/adapter_types';
import { CMTagsAdapter } from './adapter_types';
@ -14,7 +20,9 @@ export class RestTagsAdapter implements CMTagsAdapter {
public async getTagsWithIds(tagIds: string[]): Promise<BeatTag[]> {
try {
return await this.REST.get<BeatTag[]>(`/api/beats/tags/${uniq(tagIds).join(',')}`);
return (await this.REST.get<ReturnTypeBulkGet<BeatTag>>(
`/api/beats/tags/${uniq(tagIds).join(',')}`
)).items;
} catch (e) {
return [];
}
@ -22,20 +30,20 @@ export class RestTagsAdapter implements CMTagsAdapter {
public async getAll(ESQuery: string): Promise<BeatTag[]> {
try {
return await this.REST.get<BeatTag[]>(`/api/beats/tags`, { ESQuery });
return (await this.REST.get<ReturnTypeList<BeatTag>>(`/api/beats/tags`, { ESQuery })).list;
} catch (e) {
return [];
}
}
public async delete(tagIds: string[]): Promise<boolean> {
return (await this.REST.delete<{ success: boolean }>(
return (await this.REST.delete<ReturnTypeBulkDelete>(
`/api/beats/tags/${uniq(tagIds).join(',')}`
)).success;
}
public async upsertTag(tag: BeatTag): Promise<BeatTag | null> {
const response = await this.REST.put<{ success: boolean }>(`/api/beats/tag/${tag.id}`, {
const response = await this.REST.put<ReturnTypeUpsert<BeatTag>>(`/api/beats/tag/${tag.id}`, {
color: tag.color,
name: tag.name,
});
@ -45,9 +53,9 @@ export class RestTagsAdapter implements CMTagsAdapter {
public async getAssignable(beats: CMBeat[]) {
try {
return await this.REST.get<BeatTag[]>(
return (await this.REST.get<ReturnTypeBulkGet<BeatTag>>(
`/api/beats/tags/assignable/${beats.map(beat => beat.id).join(',')}`
);
)).items;
} catch (e) {
return [];
}

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ReturnTypeBulkCreate } from '../../../../common/return_types';
import { RestAPIAdapter } from '../rest_api/adapter_types';
import { CMTokensAdapter } from './adapter_types';
@ -11,9 +12,12 @@ export class RestTokensAdapter implements CMTokensAdapter {
constructor(private readonly REST: RestAPIAdapter) {}
public async createEnrollmentTokens(numTokens: number = 1): Promise<string[]> {
const tokens = (await this.REST.post<{ tokens: string[] }>('/api/beats/enrollment_tokens', {
num_tokens: numTokens,
})).tokens;
return tokens;
const results = (await this.REST.post<ReturnTypeBulkCreate<string>>(
'/api/beats/enrollment_tokens',
{
num_tokens: numTokens,
}
)).results;
return results.map(result => result.item);
}
}

View file

@ -4,13 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ReturnTypeBulkAction } from '../../common/return_types';
import { CMBeat } from './../../common/domain_types';
import {
BeatsRemovalReturn,
BeatsTagAssignment,
CMAssignmentReturn,
CMBeatsAdapter,
} from './adapters/beats/adapter_types';
import { BeatsTagAssignment, CMBeatsAdapter } from './adapters/beats/adapter_types';
import { ElasticsearchLib } from './elasticsearch';
export class BeatsLib {
@ -56,14 +52,14 @@ export class BeatsLib {
/** unassign tags from beats using an array of tags and beats */
public removeTagsFromBeats = async (
removals: BeatsTagAssignment[]
): Promise<BeatsRemovalReturn[]> => {
): Promise<ReturnTypeBulkAction['results']> => {
return await this.adapter.removeTagsFromBeats(removals);
};
/** assign tags from beats using an array of tags and beats */
public assignTagsToBeats = async (
assignments: BeatsTagAssignment[]
): Promise<CMAssignmentReturn[]> => {
): Promise<ReturnTypeBulkAction['results']> => {
return await this.adapter.assignTagsToBeats(assignments);
};
}

View file

@ -23,7 +23,7 @@ export class ConfigBlocksLib {
public getForTags = async (tagIds: string[], page: number) => {
const result = await this.adapter.getForTags(tagIds, page);
result.blocks = this.jsonConfigToUserYaml(result.blocks);
result.list = this.jsonConfigToUserYaml(result.list);
return result;
};

View file

@ -60,7 +60,7 @@ class BeatDetailPageUi extends React.PureComponent<PageProps, PageState> {
);
this.setState({
configuration_blocks: blocksResult.blocks,
configuration_blocks: blocksResult.list,
tags,
});
}

View file

@ -61,7 +61,7 @@ class TagCreatePageComponent extends React.PureComponent<
<TagEdit
tag={this.state.tag}
configuration_blocks={{
blocks: this.state.configuration_blocks.slice(
list: this.state.configuration_blocks.slice(
blockStartingIndex,
5 + blockStartingIndex
),
@ -140,8 +140,8 @@ class TagCreatePageComponent extends React.PureComponent<
const createBlocksResponse = await this.props.libs.configBlocks.upsert(
this.state.configuration_blocks.map(block => ({ ...block, tag: this.state.tag.id }))
);
const creationError = createBlocksResponse.reduce(
(err: string, resp: any) => (!err ? (err = resp.error || '') : err),
const creationError = createBlocksResponse.results.reduce(
(err: string, resp) => (!err ? (err = resp.error ? resp.error.message : '') : err),
''
);
if (creationError) {

View file

@ -22,7 +22,7 @@ interface TagPageState {
beatsTags: BeatTag[];
configuration_blocks: {
error?: string | undefined;
blocks: ConfigurationBlock[];
list: ConfigurationBlock[];
page: number;
total: number;
};
@ -47,7 +47,7 @@ class TagEditPageComponent extends React.PureComponent<
hasConfigurationBlocksTypes: [],
},
configuration_blocks: {
blocks: [],
list: [],
page: 0,
total: 0,
},
@ -160,7 +160,12 @@ class TagEditPageComponent extends React.PureComponent<
const blocksResponse = await this.props.libs.configBlocks.getForTags([this.state.tag.id], page);
this.setState({
configuration_blocks: blocksResponse,
configuration_blocks: blocksResponse as {
error?: string | undefined;
list: ConfigurationBlock[];
page: number;
total: number;
},
});
};
@ -189,7 +194,7 @@ class TagEditPageComponent extends React.PureComponent<
this.props.goTo(`/overview/configuration_tags`);
};
private getNumExclusiveConfigurationBlocks = () =>
this.state.configuration_blocks.blocks
this.state.configuration_blocks.list
.map(({ type }) => UNIQUENESS_ENFORCING_TYPES.some(uniqueType => uniqueType === type))
.reduce((acc, cur) => (cur ? acc + 1 : acc), 0);
}

View file

@ -45,10 +45,7 @@ export class InitialTagPage extends Component<AppPageProps, PageState> {
<TagEdit
tag={this.state.tag}
configuration_blocks={{
blocks: this.state.configuration_blocks.slice(
blockStartingIndex,
5 + blockStartingIndex
),
list: this.state.configuration_blocks.slice(blockStartingIndex, 5 + blockStartingIndex),
page: this.state.currentConfigPage,
total: this.state.configuration_blocks.length,
}}
@ -123,8 +120,8 @@ export class InitialTagPage extends Component<AppPageProps, PageState> {
const createBlocksResponse = await this.props.libs.configBlocks.upsert(
this.state.configuration_blocks.map(block => ({ ...block, tag: this.state.tag.id }))
);
const creationError = createBlocksResponse.reduce(
(err: string, resp: any) => (!err ? (err = resp.error || '') : err),
const creationError = createBlocksResponse.results.reduce(
(err: string, resp) => (!err ? (err = resp.error ? resp.error.message : '') : err),
''
);
if (creationError) {

View file

@ -1,29 +1,32 @@
# Documentation for Beats CM in x-pack kibana
# Beats CM
Notes:
Falure to have auth enabled in Kibana will make for a broken UI. UI based errors not yet in place
### Run tests
## Testing
```
node scripts/jest.js plugins/beats --watch
```
### Unit tests
and for functional... (from x-pack root)
From `~/kibana/x-pack`, run `node scripts/jest.js plugins/beats --watch`.
```
node scripts/functional_tests --config test/api_integration/config
```
### API tests
### Run command to fake an enrolling beat (from beats_management dir)
In one shell, from **~/kibana/x-pack**:
`node scripts/functional_tests-server.js`
In another shell, from **~kibana/x-pack**:
`node ../scripts/functional_test_runner.js --config test/api_integration/config.js`.
### Manual e2e testing
- Run this command to fake an enrolling beat (from beats_management dir)
```
node scripts/enroll.js <enrollment token>
```
### Run a command to setup a fake large-scale deployment
Note: ts-node is required to be installed gloably from NPM/Yarn for this action
- Run a command to setup a fake large-scale deployment
Note: `ts-node` is required to be installed gloably from NPM/Yarn for this action
```
ts-node scripts/fake_env.ts <KIBANA BASE PATH> <# of beats> <# of tags per beat> <# of congifs per tag>

View file

@ -171,6 +171,6 @@ export interface FrameworkRouteOptions<
export type FrameworkRouteHandler<
RouteRequest extends KibanaServerRequest,
RouteResponse extends FrameworkResponse
> = (request: FrameworkRequest<RouteRequest>, h: ResponseToolkit) => void;
> = (request: FrameworkRequest<RouteRequest>, h: ResponseToolkit) => Promise<RouteResponse>;
export type FrameworkResponse = Lifecycle.ReturnValue;

View file

@ -82,7 +82,6 @@ export class CMBeatsDomain {
}
const user = typeof userOrToken === 'string' ? this.framework.internalUser : userOrToken;
await this.adapter.update(user, {
...beat,
...beatData,

View file

@ -4,14 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { ResponseObject, ResponseToolkit } from 'hapi';
import { difference } from 'lodash';
import { BaseReturnType } from '../../common/return_types';
import {
BackendFrameworkAdapter,
FrameworkRequest,
FrameworkResponse,
FrameworkRouteHandler,
FrameworkRouteOptions,
} from './adapters/framework/adapter_types';
export class BackendFrameworkLib {
@ -26,13 +25,18 @@ export class BackendFrameworkLib {
public registerRoute<
RouteRequest extends FrameworkRequest,
RouteResponse extends FrameworkResponse
>(route: FrameworkRouteOptions<RouteRequest, RouteResponse>) {
>(route: {
path: string;
method: string | string[];
licenseRequired?: string[];
requiredRoles?: string[];
handler: (request: FrameworkRequest<RouteRequest>) => Promise<BaseReturnType>;
config?: {};
}) {
this.adapter.registerRoute({
...route,
handler: this.wrapRouteWithSecurity(
route.handler,
route.licenseRequired || [],
route.requiredRoles
handler: this.wrapErrors(
this.wrapRouteWithSecurity(route.handler, route.licenseRequired || [], route.requiredRoles)
),
});
}
@ -71,25 +75,35 @@ export class BackendFrameworkLib {
}
private wrapRouteWithSecurity(
handler: FrameworkRouteHandler<any, any>,
handler: (request: FrameworkRequest<any>) => Promise<BaseReturnType>,
requiredLicense: string[],
requiredRoles?: string[]
) {
return async (request: FrameworkRequest, h: any) => {
): (request: FrameworkRequest) => Promise<BaseReturnType> {
return async (request: FrameworkRequest) => {
if (
requiredLicense.length > 0 &&
(this.license.expired || !requiredLicense.includes(this.license.type))
) {
return Boom.forbidden(
`Your ${
this.license.type
} license does not support this API or is expired. Please upgrade your license.`
);
return {
error: {
message: `Your ${
this.license.type
} license does not support this API or is expired. Please upgrade your license.`,
code: 403,
},
success: false,
};
}
if (requiredRoles) {
if (request.user.kind !== 'authenticated') {
return h.response().code(403);
return {
error: {
message: `Request must be authenticated`,
code: 403,
},
success: false,
};
}
if (
@ -97,10 +111,67 @@ export class BackendFrameworkLib {
!request.user.roles.includes('superuser') &&
difference(requiredRoles, request.user.roles).length !== 0
) {
return h.response().code(403);
return {
error: {
message: `Request must be authenticated by a user with one of the following user roles: ${requiredRoles.join(
','
)}`,
code: 403,
},
success: false,
};
}
}
return await handler(request, h);
return await handler(request);
};
}
private wrapErrors(
handler: (request: FrameworkRequest<any>) => Promise<BaseReturnType>
): (request: FrameworkRequest, h: ResponseToolkit) => Promise<ResponseObject> {
return async (request: FrameworkRequest, h: ResponseToolkit) => {
try {
const result = await handler(request);
if (!result.error) {
return h.response(result);
}
return h
.response({
error: result.error,
success: false,
})
.code(result.error.code || 400);
} catch (err) {
let statusCode = err.statusCode;
// This is the only known non-status code error in the system, but just in case we have an else
if (!statusCode && (err.message as string).includes('Invalid user type')) {
statusCode = 403;
} else {
statusCode = 500;
}
if (statusCode === 403) {
return h
.response({
error: {
message: 'Insufficient user permissions for managing Beats configuration',
code: 403,
},
success: false,
})
.code(403);
}
return h
.response({
error: {
message: err.message,
code: statusCode,
},
success: false,
})
.code(statusCode);
}
};
}
}

View file

@ -33,7 +33,7 @@ describe('assign_tags_to_beats', () => {
});
expect(statusCode).toEqual(200);
expect(result.assignments).toEqual([{ status: 200, result: 'updated' }]);
expect(result.results).toEqual([{ success: true, result: { message: 'updated' } }]);
});
it('should not re-add an existing tag to a beat', async () => {
@ -52,7 +52,7 @@ describe('assign_tags_to_beats', () => {
expect(statusCode).toEqual(200);
expect(result.assignments).toEqual([{ status: 200, result: 'updated' }]);
expect(result.results).toEqual([{ success: true, result: { message: 'updated' } }]);
const beat = await serverLibs.beats.getById(
{
@ -79,9 +79,9 @@ describe('assign_tags_to_beats', () => {
expect(statusCode).toEqual(200);
expect(result.assignments).toEqual([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
expect(result.results).toEqual([
{ success: true, result: { message: 'updated' } },
{ success: true, result: { message: 'updated' } },
]);
let beat;
@ -121,9 +121,9 @@ describe('assign_tags_to_beats', () => {
expect(statusCode).toEqual(200);
expect(result.assignments).toEqual([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
expect(result.results).toEqual([
{ success: true, result: { message: 'updated' } },
{ success: true, result: { message: 'updated' } },
]);
const beat = await serverLibs.beats.getById(

View file

@ -5,8 +5,9 @@
*/
import Joi from 'joi';
import { ConfigurationBlock } from '../../../common/domain_types';
import { BaseReturnType, ReturnTypeList } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
export const createGetBeatConfigurationRoute = (libs: CMServerLibs) => ({
method: 'GET',
@ -19,43 +20,43 @@ export const createGetBeatConfigurationRoute = (libs: CMServerLibs) => ({
},
auth: false,
},
handler: async (request: any, h: any) => {
handler: async (
request: FrameworkRequest
): Promise<BaseReturnType | ReturnTypeList<ConfigurationBlock>> => {
const beatId = request.params.beatId;
const accessToken = request.headers['kbn-beats-access-token'];
let configurationBlocks: ConfigurationBlock[];
try {
const beat = await libs.beats.getById(libs.framework.internalUser, beatId);
if (beat === null) {
return h.response({ message: `Beat "${beatId}" not found` }).code(404);
}
const isAccessTokenValid = beat.access_token === accessToken;
if (!isAccessTokenValid) {
return h.response({ message: 'Invalid access token' }).code(401);
}
const beat = await libs.beats.getById(libs.framework.internalUser, beatId);
if (beat === null) {
return { error: { message: `Beat "${beatId}" not found`, code: 404 }, success: false };
}
await libs.beats.update(libs.framework.internalUser, beat.id, {
last_checkin: new Date(),
});
const isAccessTokenValid = beat.access_token === accessToken;
if (!isAccessTokenValid) {
return { error: { message: 'Invalid access token', code: 401 }, success: false };
}
if (beat.tags) {
const result = await libs.configurationBlocks.getForTags(
libs.framework.internalUser,
beat.tags,
-1
);
await libs.beats.update(libs.framework.internalUser, beat.id, {
last_checkin: new Date(),
});
configurationBlocks = result.blocks;
} else {
configurationBlocks = [];
}
} catch (err) {
return wrapEsError(err);
if (beat.tags) {
const result = await libs.configurationBlocks.getForTags(
libs.framework.internalUser,
beat.tags,
-1
);
configurationBlocks = result.blocks;
} else {
configurationBlocks = [];
}
return {
configuration_blocks: configurationBlocks,
list: configurationBlocks,
success: true,
};
},
});

View file

@ -6,9 +6,10 @@
import Joi from 'joi';
import { omit } from 'lodash';
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { CMBeat } from '../../../common/domain_types';
import { BaseReturnType, ReturnTypeCreate } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { BeatEnrollmentStatus, CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
// TODO: write to Kibana audit log file https://github.com/elastic/kibana/issues/26024
export const createBeatEnrollmentRoute = (libs: CMServerLibs) => ({
@ -31,38 +32,38 @@ export const createBeatEnrollmentRoute = (libs: CMServerLibs) => ({
}).required(),
},
},
handler: async (request: FrameworkRequest, h: any) => {
handler: async (
request: FrameworkRequest
): Promise<BaseReturnType | ReturnTypeCreate<CMBeat>> => {
const { beatId } = request.params;
const enrollmentToken = request.headers['kbn-beats-enrollment-token'];
try {
const { status, accessToken } = await libs.beats.enrollBeat(
enrollmentToken,
beatId,
request.info.remoteAddress,
omit(request.payload, 'enrollment_token')
);
const { status, accessToken } = await libs.beats.enrollBeat(
enrollmentToken,
beatId,
request.info.remoteAddress,
omit(request.payload, 'enrollment_token')
);
switch (status) {
case BeatEnrollmentStatus.ExpiredEnrollmentToken:
return h
.response({
message: BeatEnrollmentStatus.ExpiredEnrollmentToken,
})
.code(400);
case BeatEnrollmentStatus.InvalidEnrollmentToken:
return h
.response({
message: BeatEnrollmentStatus.InvalidEnrollmentToken,
})
.code(400);
case BeatEnrollmentStatus.Success:
default:
return h.response({ access_token: accessToken }).code(201);
}
} catch (err) {
// FIXME move this to kibana route thing in adapter
return wrapEsError(err);
switch (status) {
case BeatEnrollmentStatus.ExpiredEnrollmentToken:
return {
error: { message: BeatEnrollmentStatus.ExpiredEnrollmentToken, code: 400 },
success: false,
};
case BeatEnrollmentStatus.InvalidEnrollmentToken:
return {
error: { message: BeatEnrollmentStatus.InvalidEnrollmentToken, code: 400 },
success: false,
};
case BeatEnrollmentStatus.Success:
default:
return {
item: accessToken,
action: 'created',
success: true,
};
}
},
});

View file

@ -4,8 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Joi from 'joi';
import { BaseReturnType, ReturnTypeBulkAction } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
export const beatEventsRoute = (libs: CMServerLibs) => ({
method: 'POST',
@ -18,29 +19,26 @@ export const beatEventsRoute = (libs: CMServerLibs) => ({
},
auth: false,
},
handler: async (request: any, h: any) => {
handler: async (request: FrameworkRequest): Promise<BaseReturnType | ReturnTypeBulkAction> => {
const beatId = request.params.beatId;
const events = request.payload;
const accessToken = request.headers['kbn-beats-access-token'];
try {
const beat = await libs.beats.getById(libs.framework.internalUser, beatId);
if (beat === null) {
return h.response({ message: `Beat "${beatId}" not found` }).code(400);
}
const isAccessTokenValid = beat.access_token === accessToken;
if (!isAccessTokenValid) {
return h.response({ message: 'Invalid access token' }).code(401);
}
const results = await libs.beatEvents.log(libs.framework.internalUser, beat.id, events);
return {
response: results,
};
} catch (err) {
return wrapEsError(err);
const beat = await libs.beats.getById(libs.framework.internalUser, beatId);
if (beat === null) {
return { error: { message: `Beat "${beatId}" not found`, code: 400 }, success: false };
}
const isAccessTokenValid = beat.access_token === accessToken;
if (!isAccessTokenValid) {
return { error: { message: `Invalid access token`, code: 401 }, success: false };
}
const results = await libs.beatEvents.log(libs.framework.internalUser, beat.id, events);
return {
results,
success: true,
};
},
});

View file

@ -5,39 +5,35 @@
*/
import { CMBeat } from '../../../common/domain_types';
import { BaseReturnType, ReturnTypeGet } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
export const createGetBeatRoute = (libs: CMServerLibs) => ({
method: 'GET',
path: '/api/beats/agent/{beatId}/{token?}',
requiredRoles: ['beats_admin'],
handler: async (request: any, h: any) => {
handler: async (request: FrameworkRequest): Promise<BaseReturnType | ReturnTypeGet<CMBeat>> => {
const beatId = request.params.beatId;
let beat: CMBeat | null;
if (beatId === 'unknown') {
try {
beat = await libs.beats.getByEnrollmentToken(request.user, request.params.token);
if (beat === null) {
return h.response().code(200);
}
} catch (err) {
return wrapEsError(err);
beat = await libs.beats.getByEnrollmentToken(request.user, request.params.token);
if (beat === null) {
return { success: false };
}
} else {
try {
beat = await libs.beats.getById(request.user, beatId);
if (beat === null) {
return h.response({ message: 'Beat not found' }).code(404);
}
} catch (err) {
return wrapEsError(err);
beat = await libs.beats.getById(request.user, beatId);
if (beat === null) {
return { error: { message: 'Beat not found', code: 404 }, success: false };
}
}
delete beat.access_token;
return beat;
return {
item: beat,
success: true,
};
},
});

View file

@ -7,9 +7,9 @@
import * as Joi from 'joi';
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { CMBeat } from '../../../common/domain_types';
import { ReturnTypeList } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
export const createListAgentsRoute = (libs: CMServerLibs) => ({
method: 'GET',
@ -27,7 +27,7 @@ export const createListAgentsRoute = (libs: CMServerLibs) => ({
ESQuery: Joi.string(),
}),
},
handler: async (request: FrameworkRequest) => {
handler: async (request: FrameworkRequest): Promise<ReturnTypeList<CMBeat>> => {
const listByAndValueParts = request.params.listByAndValue
? request.params.listByAndValue.split('/')
: [];
@ -39,27 +39,22 @@ export const createListAgentsRoute = (libs: CMServerLibs) => ({
listByValue = listByAndValueParts[1];
}
try {
let beats: CMBeat[];
let beats: CMBeat[];
switch (listBy) {
case 'tag':
beats = await libs.beats.getAllWithTag(request.user, listByValue || '');
break;
switch (listBy) {
case 'tag':
beats = await libs.beats.getAllWithTag(request.user, listByValue || '');
break;
default:
beats = await libs.beats.getAll(
request.user,
request.query && request.query.ESQuery ? JSON.parse(request.query.ESQuery) : undefined
);
default:
beats = await libs.beats.getAll(
request.user,
request.query && request.query.ESQuery ? JSON.parse(request.query.ESQuery) : undefined
);
break;
}
return { beats };
} catch (err) {
// FIXME move this to kibana route thing in adapter
return wrapEsError(err);
break;
}
return { list: beats, success: true, page: -1, total: -1 };
},
});

View file

@ -6,10 +6,10 @@
import Joi from 'joi';
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { ReturnTypeBulkAction } from '../../../common/return_types';
import { BeatsTagAssignment } from '../../../public/lib/adapters/beats/adapter_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
// TODO: write to Kibana audit log file https://github.com/elastic/kibana/issues/26024
export const createTagAssignmentsRoute = (libs: CMServerLibs) => ({
@ -29,15 +29,29 @@ export const createTagAssignmentsRoute = (libs: CMServerLibs) => ({
}).required(),
},
},
handler: async (request: FrameworkRequest) => {
handler: async (request: FrameworkRequest): Promise<ReturnTypeBulkAction> => {
const { assignments }: { assignments: BeatsTagAssignment[] } = request.payload;
try {
const response = await libs.beats.assignTagsToBeats(request.user, assignments);
return response;
} catch (err) {
// TODO move this to kibana route thing in adapter
return wrapEsError(err);
}
const response = await libs.beats.assignTagsToBeats(request.user, assignments);
return {
success: true,
results: response.assignments.map(assignment => ({
success: assignment.status && assignment.status >= 200 && assignment.status < 300,
error:
!assignment.status || assignment.status >= 300
? {
code: assignment.status || 400,
message: assignment.result,
}
: undefined,
result:
assignment.status && assignment.status >= 200 && assignment.status < 300
? {
message: assignment.result,
}
: undefined,
})),
} as ReturnTypeBulkAction;
},
});

View file

@ -6,9 +6,9 @@
import Joi from 'joi';
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { ReturnTypeBulkAction } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
// TODO: write to Kibana audit log file https://github.com/elastic/kibana/issues/26024
export const createTagRemovalsRoute = (libs: CMServerLibs) => ({
@ -28,15 +28,29 @@ export const createTagRemovalsRoute = (libs: CMServerLibs) => ({
}).required(),
},
},
handler: async (request: FrameworkRequest) => {
handler: async (request: FrameworkRequest): Promise<ReturnTypeBulkAction> => {
const { removals } = request.payload;
try {
const response = await libs.beats.removeTagsFromBeats(request.user, removals);
return response;
} catch (err) {
// TODO move this to kibana route thing in adapter
return wrapEsError(err);
}
const response = await libs.beats.removeTagsFromBeats(request.user, removals);
return {
success: true,
results: response.removals.map(removal => ({
success: removal.status && removal.status >= 200 && removal.status < 300,
error:
!removal.status || removal.status >= 300
? {
code: removal.status || 400,
message: removal.result,
}
: undefined,
result:
removal.status && removal.status >= 200 && removal.status < 300
? {
message: removal.result,
}
: undefined,
})),
} as ReturnTypeBulkAction;
},
});

View file

@ -5,10 +5,11 @@
*/
import Joi from 'joi';
import { CMBeat } from '../../../common/domain_types';
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { BaseReturnType, ReturnTypeUpdate } from '../../../common/return_types';
import { FrameworkRequest, internalUser } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
// TODO: write to Kibana audit log file (include who did the verification as well) https://github.com/elastic/kibana/issues/26024
export const createBeatUpdateRoute = (libs: CMServerLibs) => ({
@ -38,34 +39,54 @@ export const createBeatUpdateRoute = (libs: CMServerLibs) => ({
}),
},
},
handler: async (request: FrameworkRequest, h: any) => {
handler: async (
request: FrameworkRequest
): Promise<BaseReturnType | ReturnTypeUpdate<CMBeat>> => {
const { beatId } = request.params;
const accessToken = request.headers['kbn-beats-access-token'];
const remoteAddress = request.info.remoteAddress;
const userOrToken = accessToken || request.user;
if (request.user.kind === 'unauthenticated' && request.payload.active !== undefined) {
return h
.response({ message: 'access-token is not a valid auth type to change beat status' })
.code(401);
return {
error: {
message: 'access-token is not a valid auth type to change beat status',
code: 401,
},
success: false,
};
}
try {
const status = await libs.beats.update(userOrToken, beatId, {
...request.payload,
host_ip: remoteAddress,
});
const status = await libs.beats.update(userOrToken, beatId, {
...request.payload,
host_ip: remoteAddress,
});
switch (status) {
case 'beat-not-found':
return h.response({ message: 'Beat not found', success: false }).code(404);
case 'invalid-access-token':
return h.response({ message: 'Invalid access token', success: false }).code(401);
}
return h.response({ success: true }).code(204);
} catch (err) {
return wrapEsError(err);
switch (status) {
case 'beat-not-found':
return {
error: {
message: 'Beat not found',
code: 404,
},
success: false,
};
case 'invalid-access-token':
return {
error: {
message: 'Invalid access token',
code: 401,
},
success: false,
};
}
const beat = await libs.beats.getById(internalUser, beatId);
return {
item: beat,
action: 'updated',
success: true,
};
},
});

View file

@ -5,22 +5,28 @@
*/
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { ReturnTypeBulkDelete } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
export const createDeleteConfidurationsRoute = (libs: CMServerLibs) => ({
method: 'DELETE',
path: '/api/beats/configurations/{ids}',
requiredRoles: ['beats_admin'],
licenseRequired: REQUIRED_LICENSES,
handler: async (request: any) => {
handler: async (request: FrameworkRequest): Promise<ReturnTypeBulkDelete> => {
const idString: string = request.params.ids;
const ids = idString.split(',').filter((id: string) => id.length > 0);
try {
return await libs.configurationBlocks.delete(request.user, ids);
} catch (err) {
return wrapEsError(err);
}
const results = await libs.configurationBlocks.delete(request.user, ids);
return {
success: true,
results: results.map(result => ({
success: result.success,
action: 'deleted',
error: result.success ? undefined : { message: result.reason },
})),
} as ReturnTypeBulkDelete;
},
});

View file

@ -5,31 +5,27 @@
*/
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { ConfigurationBlock } from '../../../common/domain_types';
import { ReturnTypeList } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
import { FrameworkRouteOptions } from './../../lib/adapters/framework/adapter_types';
export const createGetConfigurationBlocksRoute = (libs: CMServerLibs): FrameworkRouteOptions => ({
export const createGetConfigurationBlocksRoute = (libs: CMServerLibs) => ({
method: 'GET',
path: '/api/beats/configurations/{tagIds}/{page?}',
requiredRoles: ['beats_admin'],
licenseRequired: REQUIRED_LICENSES,
handler: async (request: any) => {
handler: async (request: FrameworkRequest): Promise<ReturnTypeList<ConfigurationBlock>> => {
const tagIdString: string = request.params.tagIds;
const tagIds = tagIdString.split(',').filter((id: string) => id.length > 0);
let tags;
try {
tags = await libs.configurationBlocks.getForTags(
request.user,
tagIds,
parseInt(request.params.page, 10),
5
);
} catch (err) {
return wrapEsError(err);
}
const result = await libs.configurationBlocks.getForTags(
request.user,
tagIds,
parseInt(request.params.page, 10),
5
);
return tags;
return { page: result.page, total: result.total, list: result.blocks, success: true };
},
});

View file

@ -16,6 +16,7 @@ import {
ConfigurationBlock,
createConfigurationBlockInterface,
} from '../../../common/domain_types';
import { ReturnTypeBulkUpsert } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
@ -30,16 +31,16 @@ export const upsertConfigurationRoute = (libs: CMServerLibs) => ({
payload: Joi.array().items(Joi.object({}).unknown(true)),
},
},
handler: async (request: FrameworkRequest) => {
const result = request.payload.map(async (block: ConfigurationBlock) => {
const assertData = createConfigurationBlockInterface().decode(block);
if (assertData.isLeft()) {
return {
error: `Error parsing block info, ${PathReporter.report(assertData)[0]}`,
};
}
handler: async (request: FrameworkRequest): Promise<ReturnTypeBulkUpsert> => {
const result = await Promise.all<any>(
request.payload.map(async (block: ConfigurationBlock) => {
const assertData = createConfigurationBlockInterface().decode(block);
if (assertData.isLeft()) {
return {
error: `Error parsing block info, ${PathReporter.report(assertData)[0]}`,
};
}
try {
const { blockID, success, error } = await libs.configurationBlocks.save(
request.user,
block
@ -49,11 +50,16 @@ export const upsertConfigurationRoute = (libs: CMServerLibs) => ({
}
return { success, blockID };
} catch (err) {
return { success: false, error: err.msg };
}
});
})
);
return Promise.all(result);
return {
results: result.map(r => ({
success: r.success as boolean,
// TODO: we need to surface this data, not hard coded
action: 'created' as 'created' | 'updated',
})),
success: true,
};
},
});

View file

@ -7,29 +7,28 @@
import { flatten } from 'lodash';
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { BeatTag } from '../../../common/domain_types';
import { ReturnTypeBulkGet } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
export const createAssignableTagsRoute = (libs: CMServerLibs) => ({
method: 'GET',
path: '/api/beats/tags/assignable/{beatIds}',
requiredRoles: ['beats_admin'],
licenseRequired: REQUIRED_LICENSES,
handler: async (request: any) => {
handler: async (request: FrameworkRequest): Promise<ReturnTypeBulkGet<BeatTag>> => {
const beatIdString: string = request.params.beatIds;
const beatIds = beatIdString.split(',').filter((id: string) => id.length > 0);
let tags: BeatTag[];
try {
const beats = await libs.beats.getByIds(request.user, beatIds);
tags = await libs.tags.getNonConflictingTags(
request.user,
flatten(beats.map(beat => beat.tags))
);
} catch (err) {
return wrapEsError(err);
}
const beats = await libs.beats.getByIds(request.user, beatIds);
const tags = await libs.tags.getNonConflictingTags(
request.user,
flatten(beats.map(beat => beat.tags))
);
return tags;
return {
items: tags,
success: true,
};
},
});

View file

@ -5,25 +5,27 @@
*/
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { ReturnTypeBulkDelete } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
export const createDeleteTagsWithIdsRoute = (libs: CMServerLibs) => ({
method: 'DELETE',
path: '/api/beats/tags/{tagIds}',
requiredRoles: ['beats_admin'],
licenseRequired: REQUIRED_LICENSES,
handler: async (request: any) => {
handler: async (request: FrameworkRequest): Promise<ReturnTypeBulkDelete> => {
const tagIdString: string = request.params.tagIds;
const tagIds = tagIdString.split(',').filter((id: string) => id.length > 0);
let success: boolean;
try {
success = await libs.tags.delete(request.user, tagIds);
} catch (err) {
return wrapEsError(err);
}
const success = await libs.tags.delete(request.user, tagIds);
return { success };
return {
results: tagIds.map(() => ({
success,
action: 'deleted',
})),
success,
} as ReturnTypeBulkDelete;
},
});

View file

@ -6,26 +6,24 @@
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { BeatTag } from '../../../common/domain_types';
import { ReturnTypeBulkGet } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
import { FrameworkRouteOptions } from './../../lib/adapters/framework/adapter_types';
export const createGetTagsWithIdsRoute = (libs: CMServerLibs): FrameworkRouteOptions => ({
export const createGetTagsWithIdsRoute = (libs: CMServerLibs) => ({
method: 'GET',
path: '/api/beats/tags/{tagIds}',
requiredRoles: ['beats_admin'],
licenseRequired: REQUIRED_LICENSES,
handler: async (request: any) => {
handler: async (request: FrameworkRequest): Promise<ReturnTypeBulkGet<BeatTag>> => {
const tagIdString: string = request.params.tagIds;
const tagIds = tagIdString.split(',').filter((id: string) => id.length > 0);
let tags: BeatTag[];
try {
tags = await libs.tags.getWithIds(request.user, tagIds);
} catch (err) {
return wrapEsError(err);
}
const tags = await libs.tags.getWithIds(request.user, tagIds);
return tags;
return {
items: tags,
success: true,
};
},
});

View file

@ -5,10 +5,11 @@
*/
import * as Joi from 'joi';
import { ReturnTypeList } from '../../../common/return_types';
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { BeatTag } from '../../../common/domain_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
export const createListTagsRoute = (libs: CMServerLibs) => ({
method: 'GET',
@ -25,17 +26,12 @@ export const createListTagsRoute = (libs: CMServerLibs) => ({
ESQuery: Joi.string(),
}),
},
handler: async (request: any) => {
let tags: BeatTag[];
try {
tags = await libs.tags.getAll(
request.user,
request.query && request.query.ESQuery ? JSON.parse(request.query.ESQuery) : undefined
);
} catch (err) {
return wrapEsError(err);
}
handler: async (request: FrameworkRequest): Promise<ReturnTypeList<BeatTag>> => {
const tags = await libs.tags.getAll(
request.user,
request.query && request.query.ESQuery ? JSON.parse(request.query.ESQuery) : undefined
);
return tags;
return { list: tags, success: true, page: -1, total: -1 };
},
});

View file

@ -6,10 +6,11 @@
import Joi from 'joi';
import { get } from 'lodash';
import { BeatTag } from '../../../common/domain_types';
import { REQUIRED_LICENSES } from '../../../common/constants';
import { ReturnTypeUpsert } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
import { wrapEsError } from '../../utils/error_wrappers';
// TODO: write to Kibana audit log file
export const createSetTagRoute = (libs: CMServerLibs) => ({
@ -28,7 +29,7 @@ export const createSetTagRoute = (libs: CMServerLibs) => ({
}),
},
},
handler: async (request: FrameworkRequest) => {
handler: async (request: FrameworkRequest): Promise<ReturnTypeUpsert<BeatTag>> => {
const defaultConfig = {
id: request.params.tagId,
name: request.params.tagId,
@ -37,13 +38,10 @@ export const createSetTagRoute = (libs: CMServerLibs) => ({
};
const config = { ...defaultConfig, ...get(request, 'payload', {}) };
try {
const id = await libs.tags.upsertTag(request.user, config);
const id = await libs.tags.upsertTag(request.user, config);
const tag = await libs.tags.getWithIds(request.user, [id]);
return { success: true, id };
} catch (err) {
// TODO move this to kibana route thing in adapter
return wrapEsError(err);
}
// TODO the action needs to be surfaced
return { success: true, item: tag[0], action: 'created' };
},
});

View file

@ -4,10 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import Joi from 'joi';
import { get } from 'lodash';
import { REQUIRED_LICENSES } from '../../../common/constants/security';
import { BaseReturnType, ReturnTypeBulkCreate } from '../../../common/return_types';
import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types';
import { CMServerLibs } from '../../lib/types';
@ -28,15 +28,30 @@ export const createTokensRoute = (libs: CMServerLibs) => ({
}).allow(null),
},
},
handler: async (request: FrameworkRequest) => {
handler: async (
request: FrameworkRequest
): Promise<BaseReturnType | ReturnTypeBulkCreate<string>> => {
const numTokens = get(request, 'payload.num_tokens', DEFAULT_NUM_TOKENS);
try {
const tokens = await libs.tokens.createEnrollmentTokens(request.user, numTokens);
return { tokens };
return {
results: tokens.map(token => ({
item: token,
success: true,
action: 'created',
})),
success: true,
};
} catch (err) {
libs.framework.log(err.message);
return Boom.internal();
return {
error: {
message: 'An error occured, please check your Kibana logs',
code: 500,
},
success: false,
};
}
},
});

View file

@ -1,7 +0,0 @@
/*
* 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.
*/
export { wrapEsError } from './wrap_es_error';

View file

@ -1,42 +0,0 @@
/*
* 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 { wrapEsError } from './wrap_es_error';
describe('wrap_es_error', () => {
describe('#wrapEsError', () => {
let originalError: any;
beforeEach(() => {
originalError = new Error('I am an error');
originalError.statusCode = 404;
});
it('should return a Boom object', () => {
const wrappedError = wrapEsError(originalError);
expect(wrappedError.isBoom).toEqual(true);
});
it('should return the correct Boom object', () => {
const wrappedError = wrapEsError(originalError);
expect(wrappedError.output.statusCode).toEqual(originalError.statusCode);
expect(wrappedError.output.payload.message).toEqual(originalError.message);
});
it('should return invalid permissions message for 403 errors', () => {
const securityError = new Error('I am an error');
// @ts-ignore
securityError.statusCode = 403;
const wrappedError = wrapEsError(securityError);
expect(wrappedError.isBoom).toEqual(true);
expect(wrappedError.message).toEqual(
'Insufficient user permissions for managing Beats configuration'
);
});
});
});

View file

@ -1,24 +0,0 @@
/*
* 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.
*/
// @ts-ignore
import Boom from 'boom';
/**
* Wraps ES errors into a Boom error response and returns it
* This also handles the permissions issue gracefully
*
* @param err Object ES error
* @return Object Boom error response
*/
export function wrapEsError(err: any) {
const statusCode = err.statusCode;
if (statusCode === 403) {
return Boom.forbidden('Insufficient user permissions for managing Beats configuration');
}
return Boom.boomify(err, { statusCode: err.statusCode });
}

View file

@ -28,7 +28,7 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.assignments).to.eql([{ status: 200, result: 'updated' }]);
expect(apiResponse.results).to.eql([{ success: true, result: { message: 'updated' } }]);
const esResponse = await es.get({
index: ES_INDEX_NAME,
@ -63,7 +63,7 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.assignments).to.eql([{ status: 200, result: 'updated' }]);
expect(apiResponse.results).to.eql([{ success: true, result: { message: 'updated' } }]);
// After adding the existing tag
esResponse = await es.get({
@ -87,9 +87,9 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.assignments).to.eql([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
expect(apiResponse.results).to.eql([
{ success: true, result: { message: 'updated' } },
{ success: true, result: { message: 'updated' } },
]);
let esResponse;
@ -126,9 +126,9 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.assignments).to.eql([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
expect(apiResponse.results).to.eql([
{ success: true, result: { message: 'updated' } },
{ success: true, result: { message: 'updated' } },
]);
const esResponse = await es.get({
@ -152,9 +152,9 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.assignments).to.eql([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
expect(apiResponse.results).to.eql([
{ success: true, result: { message: 'updated' } },
{ success: true, result: { message: 'updated' } },
]);
let esResponse;
@ -191,8 +191,8 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.assignments).to.eql([
{ status: 404, result: `Beat ${nonExistentBeatId} not found` },
expect(apiResponse.results).to.eql([
{ success: false, error: { code: 404, message: `Beat ${nonExistentBeatId} not found` } },
]);
});
@ -207,8 +207,8 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.assignments).to.eql([
{ status: 404, result: `Tag ${nonExistentTag} not found` },
expect(apiResponse.results).to.eql([
{ success: false, error: { code: 404, message: `Tag ${nonExistentTag} not found` } },
]);
const esResponse = await es.get({
@ -232,8 +232,14 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.assignments).to.eql([
{ status: 404, result: `Beat ${nonExistentBeatId} and tag ${nonExistentTag} not found` },
expect(apiResponse.results).to.eql([
{
success: false,
error: {
code: 404,
message: `Beat ${nonExistentBeatId} and tag ${nonExistentTag} not found`,
},
},
]);
const esResponse = await es.get({

View file

@ -20,7 +20,7 @@ export default function ({ getService }) {
.send()
.expect(200);
const tokensFromApi = apiResponse.tokens;
const tokensFromApi = apiResponse.results.map(r => r.item);
const esResponse = await es.search({
index: ES_INDEX_NAME,
@ -44,7 +44,7 @@ export default function ({ getService }) {
})
.expect(200);
const tokensFromApi = apiResponse.tokens;
const tokensFromApi = apiResponse.results.map(r => r.item);
const esResponse = await es.search({
index: ES_INDEX_NAME,

View file

@ -58,7 +58,7 @@ export default function ({ getService }) {
.set('kbn-xsrf', 'xxx')
.set('kbn-beats-enrollment-token', validEnrollmentToken)
.send(beat)
.expect(201);
.expect(200);
const esResponse = await es.get({
index: ES_INDEX_NAME,
@ -75,9 +75,9 @@ export default function ({ getService }) {
.set('kbn-xsrf', 'xxx')
.set('kbn-beats-enrollment-token', validEnrollmentToken)
.send(beat)
.expect(201);
.expect(200);
const accessTokenFromApi = apiResponse.access_token;
const accessTokenFromApi = apiResponse.item;
const esResponse = await es.get({
index: ES_INDEX_NAME,
@ -98,7 +98,10 @@ export default function ({ getService }) {
.send(beat)
.expect(400);
expect(apiResponse).to.eql({ message: 'Invalid enrollment token' });
expect(apiResponse).to.eql({
success: false,
error: { code: 400, message: 'Invalid enrollment token' },
});
});
it('should reject an expired enrollment token', async () => {
@ -128,7 +131,10 @@ export default function ({ getService }) {
.send(beat)
.expect(400);
expect(apiResponse).to.eql({ message: 'Expired enrollment token' });
expect(apiResponse).to.eql({
success: false,
error: { code: 400, message: 'Expired enrollment token' },
});
});
it('should delete the given enrollment token so it may not be reused', async () => {
@ -137,7 +143,7 @@ export default function ({ getService }) {
.set('kbn-xsrf', 'xxx')
.set('kbn-beats-enrollment-token', validEnrollmentToken)
.send(beat)
.expect(201);
.expect(200);
const esResponse = await es.get({
index: ES_INDEX_NAME,
@ -154,7 +160,7 @@ export default function ({ getService }) {
.set('kbn-xsrf', 'xxx')
.set('kbn-beats-enrollment-token', validEnrollmentToken)
.send(beat)
.expect(201);
.expect(200);
await es.index({
index: ES_INDEX_NAME,
@ -175,7 +181,7 @@ export default function ({ getService }) {
.set('kbn-xsrf', 'xxx')
.set('kbn-beats-enrollment-token', validEnrollmentToken)
.send(beat)
.expect(201);
.expect(200);
});
});
}

View file

@ -47,7 +47,7 @@ export default function ({ getService }) {
)
.expect(200);
const configurationBlocks = apiResponse.configuration_blocks;
const configurationBlocks = apiResponse.list;
expect(configurationBlocks).to.be.an(Array);
expect(configurationBlocks.length).to.be(0);
@ -64,7 +64,7 @@ export default function ({ getService }) {
)
.expect(200);
const configurationBlocks = apiResponse.configuration_blocks;
const configurationBlocks = apiResponse.list;
expect(configurationBlocks).to.be.an(Array);
expect(configurationBlocks.length).to.be(3);

View file

@ -10,10 +10,11 @@ export default function ({ getService, loadTestFile }) {
const es = getService('es');
describe('beats', () => {
const cleanup = () => es.indices.delete({
index: ES_INDEX_NAME,
ignore: [404]
});
const cleanup = () =>
es.indices.delete({
index: ES_INDEX_NAME,
ignore: [404],
});
beforeEach(cleanup);

View file

@ -19,7 +19,7 @@ export default function ({ getService }) {
it('should return all beats', async () => {
const { body: apiResponse } = await supertest.get('/api/beats/agents').expect(200);
const beatsFromApi = apiResponse.beats;
const beatsFromApi = apiResponse.list;
expect(beatsFromApi.length).to.be(4);
expect(beatsFromApi.filter(beat => beat.hasOwnProperty('verified_on')).length).to.be(1);
@ -29,7 +29,7 @@ export default function ({ getService }) {
it('should not return access tokens', async () => {
const { body: apiResponse } = await supertest.get('/api/beats/agents').expect(200);
const beatsFromApi = apiResponse.beats;
const beatsFromApi = apiResponse.list;
expect(beatsFromApi.length).to.be(4);
expect(beatsFromApi.filter(beat => beat.hasOwnProperty('access_token')).length).to.be(0);

View file

@ -28,7 +28,7 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.removals).to.eql([{ status: 200, result: 'updated' }]);
expect(apiResponse.results).to.eql([{ success: true, result: { message: 'updated' } }]);
const esResponse = await es.get({
index: ES_INDEX_NAME,
@ -48,9 +48,9 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.removals).to.eql([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
expect(apiResponse.results).to.eql([
{ success: true, result: { message: 'updated' } },
{ success: true, result: { message: 'updated' } },
]);
let esResponse;
@ -84,9 +84,9 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.removals).to.eql([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
expect(apiResponse.results).to.eql([
{ success: true, result: { message: 'updated' } },
{ success: true, result: { message: 'updated' } },
]);
const esResponse = await es.get({
@ -107,9 +107,9 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.removals).to.eql([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
expect(apiResponse.results).to.eql([
{ success: true, result: { message: 'updated' } },
{ success: true, result: { message: 'updated' } },
]);
let esResponse;
@ -145,8 +145,8 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.removals).to.eql([
{ status: 404, result: `Beat ${nonExistentBeatId} not found` },
expect(apiResponse.results).to.eql([
{ success: false, error: { code: 404, message: `Beat ${nonExistentBeatId} not found` } },
]);
});
@ -161,8 +161,8 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.removals).to.eql([
{ status: 404, result: `Tag ${nonExistentTag} not found` },
expect(apiResponse.results).to.eql([
{ success: false, error: { code: 404, message: `Tag ${nonExistentTag} not found` } },
]);
const esResponse = await es.get({
@ -186,8 +186,14 @@ export default function ({ getService }) {
})
.expect(200);
expect(apiResponse.removals).to.eql([
{ status: 404, result: `Beat ${nonExistentBeatId} and tag ${nonExistentTag} not found` },
expect(apiResponse.results).to.eql([
{
success: false,
error: {
code: 404,
message: `Beat ${nonExistentBeatId} and tag ${nonExistentTag} not found`,
},
},
]);
const esResponse = await es.get({

View file

@ -31,7 +31,7 @@ export default function ({ getService }) {
config: { elasticsearch: { hosts: ['localhost:9200'], username: 'foo' } },
},
])
.expect(201);
.expect(200);
const esResponse = await es.get({
index: ES_INDEX_NAME,
id: `tag:${tagId}`,

View file

@ -25,6 +25,7 @@ export default function ({ getService }) {
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' +
'eyJjcmVhdGVkIjoiMjAxOC0wNi0zMFQwMzo0MjoxNS4yMzBaIiwiaWF0IjoxNTMwMzMwMTM1fQ.' +
'SSsX2Byyo1B1bGxV8C3G4QldhE5iH87EY_1r21-bwbI';
const version =
chance.integer({ min: 1, max: 10 }) +
'.' +
@ -62,9 +63,14 @@ export default function ({ getService }) {
await supertest
.put(`/api/beats/agent/${beatId}`)
.set('kbn-xsrf', 'xxx')
.set('kbn-beats-access-token', validEnrollmentToken)
.set(
'kbn-beats-access-token',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' +
'eyJjcmVhdGVkIjoiMjAxOC0wNi0zMFQwMzo0MjoxNS4yMzBaIiwiaWF0IjoxNTMwMzMwMTM1fQ.' +
'SSsX2Byyo1B1bGxV8C3G4QldhE5iH87EY_1r21-bwbI'
)
.send(beat)
.expect(204);
.expect(200);
const beatInEs = await es.get({
index: ES_INDEX_NAME,
@ -88,7 +94,7 @@ export default function ({ getService }) {
.send(beat)
.expect(401);
expect(body.message).to.be('Invalid access token');
expect(body.error.message).to.be('Invalid access token');
const beatInEs = await es.get({
index: ES_INDEX_NAME,
@ -111,7 +117,7 @@ export default function ({ getService }) {
.send(beat)
.expect(404);
expect(body.message).to.be('Beat not found');
expect(body.error.message).to.be('Beat not found');
});
});
}