[RAM] Category fields endpoint (#138245)

* first commit

* get auth index and try field caps

* use esClient

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* wait for promise to finish

* format field capabilities

* add simplier browserFields mapper

* update response

* refactor

* types and refactor

* remove browser fields dependency

* update fn name

* update types

* update imported type package

* update mock object

* error message for no o11y alert indices

* add endpoint integration test

* activate commented tests

* add unit test

* comment uncommented tests

* fix tests

* review by Xavier

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* update param names + right type

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
This commit is contained in:
Julian Gernun 2022-09-12 16:05:23 +02:00 committed by GitHub
parent cc5ff75733
commit f236196ad2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 308 additions and 0 deletions

View file

@ -19,6 +19,7 @@ const createAlertsClientMock = () => {
bulkUpdate: jest.fn(),
find: jest.fn(),
getFeatureIdsByRegistrationContexts: jest.fn(),
getBrowserFields: jest.fn(),
};
return mocked;
};

View file

@ -30,6 +30,7 @@ import {
} from '@kbn/alerting-plugin/server';
import { Logger, ElasticsearchClient, EcsEventOutcome } from '@kbn/core/server';
import { AuditLogger } from '@kbn/security-plugin/server';
import { IndexPatternsFetcher } from '@kbn/data-plugin/server';
import { alertAuditEvent, operationAlertAuditActionMap } from './audit_events';
import {
ALERT_WORKFLOW_STATUS,
@ -40,6 +41,8 @@ import {
import { ParsedTechnicalFields } from '../../common/parse_technical_fields';
import { Dataset, IRuleDataService } from '../rule_data_plugin_service';
import { getAuthzFilter, getSpacesFilter } from '../lib';
import { fieldDescriptorToBrowserFieldMapper } from './browser_fields';
import { BrowserFields } from '../types';
// TODO: Fix typings https://github.com/elastic/kibana/issues/101776
type NonNullableProps<Obj extends {}, Props extends keyof Obj> = Omit<Obj, Props> & {
@ -716,4 +719,23 @@ export class AlertsClient {
throw Boom.failedDependency(errMessage);
}
}
async getBrowserFields({
indices,
metaFields,
allowNoIndex,
}: {
indices: string[];
metaFields: string[];
allowNoIndex: boolean;
}): Promise<BrowserFields> {
const indexPatternsFetcherAsInternalUser = new IndexPatternsFetcher(this.esClient);
const { fields } = await indexPatternsFetcherAsInternalUser.getFieldsForWildcard({
pattern: indices,
metaFields,
fieldCapsOptions: { allow_no_indices: allowNoIndex },
});
return fieldDescriptorToBrowserFieldMapper(fields);
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FieldDescriptor } from '@kbn/data-views-plugin/server';
import { BrowserField, BrowserFields } from '../../types';
const getFieldCategory = (fieldCapability: FieldDescriptor) => {
const name = fieldCapability.name.split('.');
if (name.length === 1) {
return 'base';
}
return name[0];
};
const browserFieldFactory = (
fieldCapability: FieldDescriptor,
category: string
): { [fieldName in string]: BrowserField } => {
return {
[fieldCapability.name]: {
...fieldCapability,
category,
},
};
};
export const fieldDescriptorToBrowserFieldMapper = (fields: FieldDescriptor[]): BrowserFields => {
return fields.reduce((browserFields: BrowserFields, field: FieldDescriptor) => {
const category = getFieldCategory(field);
const browserField = browserFieldFactory(field, category);
if (browserFields[category]) {
browserFields[category] = { fields: { ...browserFields[category].fields, ...browserField } };
} else {
browserFields[category] = { fields: browserField };
}
return browserFields;
}, {});
};

View file

@ -39,3 +39,10 @@ export const getReadFeatureIdsRequest = () =>
path: `${BASE_RAC_ALERTS_API_PATH}/_feature_ids`,
query: { registrationContext: ['security'] },
});
export const getO11yBrowserFields = () =>
requestMock.create({
method: 'get',
path: `${BASE_RAC_ALERTS_API_PATH}/browser_fields`,
query: { featureIds: ['apm', 'logs'] },
});

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants';
import { getBrowserFieldsByFeatureId } from './get_browser_fields_by_feature_id';
import { requestContextMock } from './__mocks__/request_context';
import { getO11yBrowserFields } from './__mocks__/request_responses';
import { requestMock, serverMock } from './__mocks__/server';
describe('getBrowserFieldsByFeatureId', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
const path = `${BASE_RAC_ALERTS_API_PATH}/browser_fields`;
beforeEach(async () => {
server = serverMock.create();
({ clients, context } = requestContextMock.createTools());
});
describe('when racClient returns o11y indices', () => {
beforeEach(() => {
clients.rac.getAuthorizedAlertsIndices.mockResolvedValue([
'.alerts-observability.logs.alerts-default',
]);
getBrowserFieldsByFeatureId(server.router);
});
test('route registered', async () => {
const response = await server.inject(getO11yBrowserFields(), context);
expect(response.status).toEqual(200);
});
test('rejects invalid featureId type', async () => {
await expect(
server.inject(
requestMock.create({
method: 'get',
path,
query: { featureIds: undefined },
}),
context
)
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Request was rejected with message: 'Invalid value \\"undefined\\" supplied to \\"featureIds\\"'"`
);
});
test('returns error status if rac client "getAuthorizedAlertsIndices" fails', async () => {
clients.rac.getAuthorizedAlertsIndices.mockRejectedValue(new Error('Unable to get index'));
const response = await server.inject(getO11yBrowserFields(), context);
expect(response.status).toEqual(500);
expect(response.body).toEqual({
attributes: { success: false },
message: 'Unable to get index',
});
});
});
});

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IRouter } from '@kbn/core/server';
import { transformError } from '@kbn/securitysolution-es-utils';
import * as t from 'io-ts';
import { RacRequestHandlerContext } from '../types';
import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants';
import { buildRouteValidation } from './utils/route_validation';
export const getBrowserFieldsByFeatureId = (router: IRouter<RacRequestHandlerContext>) => {
router.get(
{
path: `${BASE_RAC_ALERTS_API_PATH}/browser_fields`,
validate: {
query: buildRouteValidation(
t.exact(
t.type({
featureIds: t.union([t.string, t.array(t.string)]),
})
)
),
},
options: {
tags: ['access:rac'],
},
},
async (context, request, response) => {
try {
const racContext = await context.rac;
const alertsClient = await racContext.getAlertsClient();
const { featureIds = [] } = request.query;
const indices = await alertsClient.getAuthorizedAlertsIndices(
Array.isArray(featureIds) ? featureIds : [featureIds]
);
const o11yIndices =
indices?.filter((index) => index.startsWith('.alerts-observability')) ?? [];
if (o11yIndices.length === 0) {
return response.notFound({
body: {
message: `No alerts-observability indices found for featureIds [${featureIds}]`,
attributes: { success: false },
},
});
}
const browserFields = await alertsClient.getBrowserFields({
indices: o11yIndices,
metaFields: ['_id', '_index'],
allowNoIndex: true,
});
return response.ok({
body: browserFields,
});
} catch (error) {
const formatedError = transformError(error);
const contentType = {
'content-type': 'application/json',
};
const defaultedHeaders = {
...contentType,
};
return response.customError({
headers: defaultedHeaders,
statusCode: formatedError.statusCode,
body: {
message: formatedError.message,
attributes: {
success: false,
},
},
});
}
}
);
};

View file

@ -13,6 +13,7 @@ import { getAlertsIndexRoute } from './get_alert_index';
import { bulkUpdateAlertsRoute } from './bulk_update_alerts';
import { findAlertsByQueryRoute } from './find';
import { getFeatureIdsByRegistrationContexts } from './get_feature_ids_by_registration_contexts';
import { getBrowserFieldsByFeatureId } from './get_browser_fields_by_feature_id';
export function defineRoutes(router: IRouter<RacRequestHandlerContext>) {
getAlertByIdRoute(router);
@ -21,4 +22,5 @@ export function defineRoutes(router: IRouter<RacRequestHandlerContext>) {
bulkUpdateAlertsRoute(router);
findAlertsByQueryRoute(router);
getFeatureIdsByRegistrationContexts(router);
getBrowserFieldsByFeatureId(router);
}

View file

@ -13,6 +13,7 @@ import {
RuleTypeState,
} from '@kbn/alerting-plugin/common';
import { RuleExecutorOptions, RuleExecutorServices, RuleType } from '@kbn/alerting-plugin/server';
import { FieldSpec } from '@kbn/data-plugin/common';
import { AlertsClient } from './alert_data_client/alerts_client';
type SimpleAlertType<
@ -71,3 +72,11 @@ export interface RacApiRequestHandlerContext {
export type RacRequestHandlerContext = CustomRequestHandlerContext<{
rac: RacApiRequestHandlerContext;
}>;
export type BrowserField = FieldSpec & {
category: string;
};
export type BrowserFields = {
[category in string]: { fields: { [fieldName in string]: BrowserField } };
};

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { superUser, obsOnlySpacesAll, secOnlyRead } from '../../../common/lib/authentication/users';
import type { User } from '../../../common/lib/authentication/types';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const supertestWithoutAuth = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
const SPACE1 = 'space1';
const TEST_URL = '/internal/rac/alerts/browser_fields';
const getBrowserFieldsByFeatureId = async (
user: User,
featureIds: string[],
expectedStatusCode: number = 200
) => {
const resp = await supertestWithoutAuth
.get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`)
.query({ featureIds })
.auth(user.username, user.password)
.set('kbn-xsrf', 'true')
.expect(expectedStatusCode);
return resp.body;
};
describe('Alert - Get browser fields by featureId', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts');
});
describe('Users:', () => {
it(`${obsOnlySpacesAll.username} should be able to get browser fields for o11y featureIds`, async () => {
const browserFields = await getBrowserFieldsByFeatureId(obsOnlySpacesAll, [
'apm',
'infrastructure',
'logs',
'uptime',
]);
expect(Object.keys(browserFields)).to.eql(['base']);
});
it(`${superUser.username} should be able to get browser fields for o11y featureIds`, async () => {
const browserFields = await getBrowserFieldsByFeatureId(superUser, [
'apm',
'infrastructure',
'logs',
'uptime',
]);
expect(Object.keys(browserFields)).to.eql(['base']);
});
it(`${superUser.username} should NOT be able to get browser fields for siem featureId`, async () => {
await getBrowserFieldsByFeatureId(superUser, ['siem'], 404);
});
it(`${secOnlyRead.username} should NOT be able to get browser fields for siem featureId`, async () => {
await getBrowserFieldsByFeatureId(secOnlyRead, ['siem'], 404);
});
});
});
};

View file

@ -29,5 +29,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => {
loadTestFile(require.resolve('./get_alerts_index'));
loadTestFile(require.resolve('./find_alerts'));
loadTestFile(require.resolve('./search_strategy'));
loadTestFile(require.resolve('./get_browser_fields_by_feature_id'));
});
};