[RAC] [RBAC] MVP RBAC for alerts as data (#100705)

An MVP of the RBAC work required for the "alerts as data" effort. An example of the existing implementation for alerts would be that of the security solution. The security solution stores its alerts generated from rules in a single data index - .siem-signals. In order to gain or restrict access to alerts, users do so by following the Elasticsearch privilege architecture. A user would need to go into the Kibana role access UI and give explicit read/write/manage permissions for the index itself.

Kibana as a whole is moving away from this model and instead having all user interactions run through the Kibana privilege model. When solutions use saved objects, this authentication layer is abstracted away for them. Because we have chosen to use data indices for alerts, we cannot rely on this abstracted out layer that saved objects provide - we need to provide our own RBAC! Instead of giving users explicit permission to an alerts index, users are instead given access to features. They don't need to know anything about indices, that work we do under the covers now.

Co-authored-by: Yara Tercero <yctercero@users.noreply.github.com>
Co-authored-by: Yara Tercero <yara.tercero@elastic.co>
This commit is contained in:
Devin W. Hurley 2021-07-08 15:24:17 -04:00 committed by GitHub
parent e9ec16ec97
commit c77c7fbedb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
118 changed files with 5700 additions and 106 deletions

View file

@ -19,6 +19,7 @@ const RULE_NAME = 'rule.name' as const;
const RULE_CATEGORY = 'rule.category' as const;
const TAGS = 'tags' as const;
const PRODUCER = `${ALERT_NAMESPACE}.producer` as const;
const OWNER = `${ALERT_NAMESPACE}.owner` as const;
const ALERT_ID = `${ALERT_NAMESPACE}.id` as const;
const ALERT_UUID = `${ALERT_NAMESPACE}.uuid` as const;
const ALERT_START = `${ALERT_NAMESPACE}.start` as const;
@ -40,6 +41,7 @@ const fields = {
RULE_CATEGORY,
TAGS,
PRODUCER,
OWNER,
ALERT_ID,
ALERT_UUID,
ALERT_START,
@ -62,6 +64,7 @@ export {
RULE_CATEGORY,
TAGS,
PRODUCER,
OWNER,
ALERT_ID,
ALERT_UUID,
ALERT_START,

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
// Similar to the src/core/server/saved_objects/version/decode_version.ts

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/**

View file

@ -8,10 +8,12 @@
export * from './bad_request_error';
export * from './create_boostrap_index';
export * from './decode_version';
export * from './delete_all_index';
export * from './delete_policy';
export * from './delete_template';
export * from './elasticsearch_client';
export * from './encode_hit_version';
export * from './get_index_aliases';
export * from './get_index_count';
export * from './get_index_exists';

View file

@ -16,12 +16,13 @@ const createAlertingAuthorizationMock = () => {
ensureAuthorized: jest.fn(),
filterByRuleTypeAuthorization: jest.fn(),
getFindAuthorizationFilter: jest.fn(),
getAugmentedRuleTypesWithAuthorization: jest.fn(),
};
return mocked;
};
export const alertingAuthorizationMock: {
create: () => AlertingAuthorizationMock;
create: () => jest.Mocked<PublicMethodsOf<AlertingAuthorization>>;
} = {
create: createAlertingAuthorizationMock,
};

View file

@ -1944,4 +1944,184 @@ describe('AlertingAuthorization', () => {
`);
});
});
describe('getAugmentedRuleTypesWithAuthorization', () => {
const myOtherAppAlertType: RegistryAlertType = {
actionGroups: [],
actionVariables: undefined,
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
recoveryActionGroup: RecoveredActionGroup,
id: 'myOtherAppAlertType',
name: 'myOtherAppAlertType',
producer: 'alerts',
enabledInLicense: true,
isExportable: true,
};
const myAppAlertType: RegistryAlertType = {
actionGroups: [],
actionVariables: undefined,
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
producer: 'myApp',
enabledInLicense: true,
isExportable: true,
};
const mySecondAppAlertType: RegistryAlertType = {
actionGroups: [],
actionVariables: undefined,
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
recoveryActionGroup: RecoveredActionGroup,
id: 'mySecondAppAlertType',
name: 'mySecondAppAlertType',
producer: 'myApp',
enabledInLicense: true,
isExportable: true,
};
const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType, mySecondAppAlertType]);
test('it returns authorized rule types given a set of feature ids', async () => {
const { authorization } = mockSecurity();
const checkPrivileges: jest.MockedFunction<
ReturnType<typeof authorization.checkPrivilegesDynamicallyWithRequest>
> = jest.fn();
authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges);
checkPrivileges.mockResolvedValueOnce({
username: 'some-user',
hasAllRequested: false,
privileges: {
kibana: [
{
privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'alert', 'find'),
authorized: true,
},
],
},
});
const alertAuthorization = new AlertingAuthorization({
request,
authorization,
alertTypeRegistry,
features,
auditLogger,
getSpace,
exemptConsumerIds,
});
alertTypeRegistry.list.mockReturnValue(setOfAlertTypes);
await expect(
alertAuthorization.getAugmentedRuleTypesWithAuthorization(
['myApp'],
[ReadOperations.Find, ReadOperations.Get, WriteOperations.Update],
AlertingAuthorizationEntity.Alert
)
).resolves.toMatchInlineSnapshot(`
Object {
"authorizedRuleTypes": Set {
Object {
"actionGroups": Array [],
"actionVariables": undefined,
"authorizedConsumers": Object {
"myApp": Object {
"all": false,
"read": true,
},
},
"defaultActionGroupId": "default",
"enabledInLicense": true,
"id": "myOtherAppAlertType",
"isExportable": true,
"minimumLicenseRequired": "basic",
"name": "myOtherAppAlertType",
"producer": "alerts",
"recoveryActionGroup": Object {
"id": "recovered",
"name": "Recovered",
},
},
},
"hasAllRequested": false,
"username": "some-user",
}
`);
});
test('it returns all authorized if user has read, get and update alert privileges', async () => {
const { authorization } = mockSecurity();
const checkPrivileges: jest.MockedFunction<
ReturnType<typeof authorization.checkPrivilegesDynamicallyWithRequest>
> = jest.fn();
authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges);
checkPrivileges.mockResolvedValueOnce({
username: 'some-user',
hasAllRequested: false,
privileges: {
kibana: [
{
privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'alert', 'find'),
authorized: true,
},
{
privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'alert', 'get'),
authorized: true,
},
{
privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'alert', 'update'),
authorized: true,
},
],
},
});
const alertAuthorization = new AlertingAuthorization({
request,
authorization,
alertTypeRegistry,
features,
auditLogger,
getSpace,
exemptConsumerIds,
});
alertTypeRegistry.list.mockReturnValue(setOfAlertTypes);
await expect(
alertAuthorization.getAugmentedRuleTypesWithAuthorization(
['myApp'],
[ReadOperations.Find, ReadOperations.Get, WriteOperations.Update],
AlertingAuthorizationEntity.Alert
)
).resolves.toMatchInlineSnapshot(`
Object {
"authorizedRuleTypes": Set {
Object {
"actionGroups": Array [],
"actionVariables": undefined,
"authorizedConsumers": Object {
"myApp": Object {
"all": true,
"read": true,
},
},
"defaultActionGroupId": "default",
"enabledInLicense": true,
"id": "myOtherAppAlertType",
"isExportable": true,
"minimumLicenseRequired": "basic",
"name": "myOtherAppAlertType",
"producer": "alerts",
"recoveryActionGroup": Object {
"id": "recovered",
"name": "Recovered",
},
},
},
"hasAllRequested": false,
"username": "some-user",
}
`);
});
});
});

View file

@ -124,20 +124,41 @@ export class AlertingAuthorization {
return new Set();
});
this.allPossibleConsumers = this.featuresIds.then((featuresIds) =>
featuresIds.size
this.allPossibleConsumers = this.featuresIds.then((featuresIds) => {
return featuresIds.size
? asAuthorizedConsumers([...this.exemptConsumerIds, ...featuresIds], {
read: true,
all: true,
})
: {}
);
: {};
});
}
private shouldCheckAuthorization(): boolean {
return this.authorization?.mode?.useRbacForRequest(this.request) ?? false;
}
/*
* This method exposes the private 'augmentRuleTypesWithAuthorization' to be
* used by the RAC/Alerts client
*/
public async getAugmentedRuleTypesWithAuthorization(
featureIds: readonly string[],
operations: Array<ReadOperations | WriteOperations>,
authorizationEntity: AlertingAuthorizationEntity
): Promise<{
username?: string;
hasAllRequested: boolean;
authorizedRuleTypes: Set<RegistryAlertTypeWithAuth>;
}> {
return this.augmentRuleTypesWithAuthorization(
this.alertTypeRegistry.list(),
operations,
authorizationEntity,
new Set(featureIds)
);
}
public async ensureAuthorized({ ruleTypeId, consumer, operation, entity }: EnsureAuthorizedOpts) {
const { authorization } = this;
@ -339,13 +360,14 @@ export class AlertingAuthorization {
private async augmentRuleTypesWithAuthorization(
ruleTypes: Set<RegistryAlertType>,
operations: Array<ReadOperations | WriteOperations>,
authorizationEntity: AlertingAuthorizationEntity
authorizationEntity: AlertingAuthorizationEntity,
featuresIds?: Set<string>
): Promise<{
username?: string;
hasAllRequested: boolean;
authorizedRuleTypes: Set<RegistryAlertTypeWithAuth>;
}> {
const featuresIds = await this.featuresIds;
const fIds = featuresIds ?? (await this.featuresIds);
if (this.authorization && this.shouldCheckAuthorization()) {
const checkPrivileges = this.authorization.checkPrivilegesDynamicallyWithRequest(
this.request
@ -363,7 +385,7 @@ export class AlertingAuthorization {
// as we can't ask ES for the user's individual privileges we need to ask for each feature
// and ruleType in the system whether this user has this privilege
for (const ruleType of ruleTypesWithAuthorization) {
for (const feature of featuresIds) {
for (const feature of fIds) {
for (const operation of operations) {
privilegeToRuleType.set(
this.authorization!.actions.alerting.get(
@ -420,7 +442,7 @@ export class AlertingAuthorization {
return {
hasAllRequested: true,
authorizedRuleTypes: this.augmentWithAuthorizedConsumers(
new Set([...ruleTypes].filter((ruleType) => featuresIds.has(ruleType.producer))),
new Set([...ruleTypes].filter((ruleType) => fIds.has(ruleType.producer))),
await this.allPossibleConsumers
),
};

View file

@ -34,6 +34,13 @@ export { FindResult } from './alerts_client';
export { PublicAlertInstance as AlertInstance } from './alert_instance';
export { parseDuration } from './lib';
export { getEsErrorMessage } from './lib/errors';
export {
ReadOperations,
AlertingAuthorizationFilterType,
AlertingAuthorization,
WriteOperations,
AlertingAuthorizationEntity,
} from './authorization';
export const plugin = (initContext: PluginInitializerContext) => new AlertingPlugin(initContext);

View file

@ -10,6 +10,8 @@ import type { ValuesType } from 'utility-types';
import type { ActionGroup } from '../../alerting/common';
import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from './ml_constants';
export const APM_SERVER_FEATURE_ID = 'apm';
export enum AlertType {
ErrorCount = 'apm.error_rate', // ErrorRate was renamed to ErrorCount but the key is kept as `error_rate` for backwards-compat.
TransactionErrorRate = 'apm.transaction_error_rate',
@ -44,7 +46,7 @@ export const ALERT_TYPES_CONFIG: Record<
actionGroups: [THRESHOLD_MET_GROUP],
defaultActionGroupId: THRESHOLD_MET_GROUP_ID,
minimumLicenseRequired: 'basic',
producer: 'apm',
producer: APM_SERVER_FEATURE_ID,
isExportable: true,
},
[AlertType.TransactionDuration]: {
@ -54,7 +56,7 @@ export const ALERT_TYPES_CONFIG: Record<
actionGroups: [THRESHOLD_MET_GROUP],
defaultActionGroupId: THRESHOLD_MET_GROUP_ID,
minimumLicenseRequired: 'basic',
producer: 'apm',
producer: APM_SERVER_FEATURE_ID,
isExportable: true,
},
[AlertType.TransactionDurationAnomaly]: {
@ -64,7 +66,7 @@ export const ALERT_TYPES_CONFIG: Record<
actionGroups: [THRESHOLD_MET_GROUP],
defaultActionGroupId: THRESHOLD_MET_GROUP_ID,
minimumLicenseRequired: 'basic',
producer: 'apm',
producer: APM_SERVER_FEATURE_ID,
isExportable: true,
},
[AlertType.TransactionErrorRate]: {
@ -74,7 +76,7 @@ export const ALERT_TYPES_CONFIG: Record<
actionGroups: [THRESHOLD_MET_GROUP],
defaultActionGroupId: THRESHOLD_MET_GROUP_ID,
minimumLicenseRequired: 'basic',
producer: 'apm',
producer: APM_SERVER_FEATURE_ID,
isExportable: true,
},
};

View file

@ -8,7 +8,10 @@
import React, { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { AlertType } from '../../../../common/alert_types';
import {
AlertType,
APM_SERVER_FEATURE_ID,
} from '../../../../common/alert_types';
import { getInitialAlertValues } from '../get_initial_alert_values';
import { ApmPluginStartDeps } from '../../../plugin';
interface Props {
@ -31,7 +34,7 @@ export function AlertingFlyout(props: Props) {
() =>
alertType &&
services.triggersActionsUi.getAddAlertFlyout({
consumer: 'apm',
consumer: APM_SERVER_FEATURE_ID,
onClose: onCloseAddFlyout,
alertTypeId: alertType,
canChangeTrigger: false,

View file

@ -6,8 +6,9 @@
*/
import { i18n } from '@kbn/i18n';
import { SubFeaturePrivilegeGroupType } from '../../features/common';
import { LicenseType } from '../../licensing/common/types';
import { AlertType } from '../common/alert_types';
import { AlertType, APM_SERVER_FEATURE_ID } from '../common/alert_types';
import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server';
import {
LicensingPluginSetup,
@ -15,14 +16,14 @@ import {
} from '../../licensing/server';
export const APM_FEATURE = {
id: 'apm',
id: APM_SERVER_FEATURE_ID,
name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', {
defaultMessage: 'APM and User Experience',
}),
order: 900,
category: DEFAULT_APP_CATEGORIES.observability,
app: ['apm', 'ux', 'kibana'],
catalogue: ['apm'],
app: [APM_SERVER_FEATURE_ID, 'ux', 'kibana'],
catalogue: [APM_SERVER_FEATURE_ID],
management: {
insightsAndAlerting: ['triggersActions'],
},
@ -30,9 +31,9 @@ export const APM_FEATURE = {
// see x-pack/plugins/features/common/feature_kibana_privileges.ts
privileges: {
all: {
app: ['apm', 'ux', 'kibana'],
api: ['apm', 'apm_write'],
catalogue: ['apm'],
app: [APM_SERVER_FEATURE_ID, 'ux', 'kibana'],
api: [APM_SERVER_FEATURE_ID, 'apm_write', 'rac'],
catalogue: [APM_SERVER_FEATURE_ID],
savedObject: {
all: [],
read: [],
@ -41,9 +42,6 @@ export const APM_FEATURE = {
rule: {
all: Object.values(AlertType),
},
alert: {
all: Object.values(AlertType),
},
},
management: {
insightsAndAlerting: ['triggersActions'],
@ -51,9 +49,9 @@ export const APM_FEATURE = {
ui: ['show', 'save', 'alerting:show', 'alerting:save'],
},
read: {
app: ['apm', 'ux', 'kibana'],
api: ['apm'],
catalogue: ['apm'],
app: [APM_SERVER_FEATURE_ID, 'ux', 'kibana'],
api: [APM_SERVER_FEATURE_ID, 'rac'],
catalogue: [APM_SERVER_FEATURE_ID],
savedObject: {
all: [],
read: [],
@ -62,9 +60,6 @@ export const APM_FEATURE = {
rule: {
read: Object.values(AlertType),
},
alert: {
read: Object.values(AlertType),
},
},
management: {
insightsAndAlerting: ['triggersActions'],
@ -72,6 +67,60 @@ export const APM_FEATURE = {
ui: ['show', 'alerting:show', 'alerting:save'],
},
},
subFeatures: [
{
name: i18n.translate('xpack.apm.featureRegistry.manageAlertsName', {
defaultMessage: 'Alerts',
}),
privilegeGroups: [
{
groupType: 'mutually_exclusive' as SubFeaturePrivilegeGroupType,
privileges: [
{
id: 'alerts_all',
name: i18n.translate(
'xpack.apm.featureRegistry.subfeature.alertsAllName',
{
defaultMessage: 'All',
}
),
includeIn: 'all' as 'all',
alerting: {
alert: {
all: Object.values(AlertType),
},
},
savedObject: {
all: [],
read: [],
},
ui: [],
},
{
id: 'alerts_read',
name: i18n.translate(
'xpack.apm.featureRegistry.subfeature.alertsReadName',
{
defaultMessage: 'Read',
}
),
includeIn: 'read' as 'read',
alerting: {
alert: {
read: Object.values(AlertType),
},
},
savedObject: {
all: [],
read: [],
},
ui: [],
},
],
},
],
},
],
};
interface Feature {

View file

@ -125,6 +125,7 @@ export function mergeConfigs(
export const plugin = (initContext: PluginInitializerContext) =>
new APMPlugin(initContext);
export { APM_SERVER_FEATURE_ID } from '../common/alert_types';
export { APMPlugin } from './plugin';
export { APMPluginSetup } from './types';
export { APMServerRouteRepository } from './routes/get_global_apm_server_route_repository';

View file

@ -17,7 +17,11 @@ import {
getEnvironmentEsField,
getEnvironmentLabel,
} from '../../../common/environment_filter_values';
import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types';
import {
AlertType,
APM_SERVER_FEATURE_ID,
ALERT_TYPES_CONFIG,
} from '../../../common/alert_types';
import {
PROCESSOR_EVENT,
SERVICE_ENVIRONMENT,
@ -69,7 +73,7 @@ export function registerErrorCountAlertType({
apmActionVariables.interval,
],
},
producer: 'apm',
producer: APM_SERVER_FEATURE_ID,
minimumLicenseRequired: 'basic',
isExportable: true,
executor: async ({ services, params }) => {

View file

@ -17,7 +17,11 @@ import {
getEnvironmentLabel,
getEnvironmentEsField,
} from '../../../common/environment_filter_values';
import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types';
import {
AlertType,
APM_SERVER_FEATURE_ID,
ALERT_TYPES_CONFIG,
} from '../../../common/alert_types';
import {
PROCESSOR_EVENT,
SERVICE_NAME,
@ -77,7 +81,7 @@ export function registerTransactionDurationAlertType({
apmActionVariables.interval,
],
},
producer: 'apm',
producer: APM_SERVER_FEATURE_ID,
minimumLicenseRequired: 'basic',
isExportable: true,
executor: async ({ services, params }) => {

View file

@ -17,7 +17,11 @@ import {
getEnvironmentLabel,
} from '../../../common/environment_filter_values';
import { createLifecycleRuleTypeFactory } from '../../../../rule_registry/server';
import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types';
import {
AlertType,
ALERT_TYPES_CONFIG,
APM_SERVER_FEATURE_ID,
} from '../../../common/alert_types';
import {
EVENT_OUTCOME,
PROCESSOR_EVENT,
@ -75,7 +79,7 @@ export function registerTransactionErrorRateAlertType({
apmActionVariables.interval,
],
},
producer: 'apm',
producer: APM_SERVER_FEATURE_ID,
minimumLicenseRequired: 'basic',
isExportable: true,
executor: async ({ services, params: alertParams }) => {

View file

@ -10,7 +10,7 @@ import { of } from 'rxjs';
import { elasticsearchServiceMock } from 'src/core/server/mocks';
import type { RuleDataClient } from '../../../../../rule_registry/server';
import { PluginSetupContract as AlertingPluginSetupContract } from '../../../../../alerting/server';
import { APMConfig } from '../../..';
import { APMConfig, APM_SERVER_FEATURE_ID } from '../../..';
export const createRuleTypeMocks = () => {
let alertExecutor: (...args: any[]) => Promise<any>;
@ -38,6 +38,9 @@ export const createRuleTypeMocks = () => {
const services = {
scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(),
savedObjectsClient: {
get: () => ({ attributes: { consumer: APM_SERVER_FEATURE_ID } }),
},
alertInstanceFactory: jest.fn(() => ({ scheduleActions })),
alertWithLifecycle: jest.fn(),
logger: loggerMock,
@ -67,6 +70,7 @@ export const createRuleTypeMocks = () => {
executor: async ({ params }: { params: Record<string, any> }) => {
return alertExecutor({
services,
rule: { consumer: APM_SERVER_FEATURE_ID },
params,
startedAt: new Date(),
});

View file

@ -18,7 +18,7 @@ import {
import { mapValues, once } from 'lodash';
import { TECHNICAL_COMPONENT_TEMPLATE_NAME } from '../../rule_registry/common/assets';
import { mappingFromFieldMap } from '../../rule_registry/common/mapping_from_field_map';
import { APMConfig, APMXPackConfig } from '.';
import { APMConfig, APMXPackConfig, APM_SERVER_FEATURE_ID } from '.';
import { mergeConfigs } from './index';
import { UI_SETTINGS } from '../../../../src/plugins/data/common';
import { APM_FEATURE, registerFeaturesUsage } from './feature';
@ -188,6 +188,7 @@ export class APMPlugin
);
const ruleDataClient = ruleDataService.getRuleDataClient(
APM_SERVER_FEATURE_ID,
ruleDataService.getFullAssetName('observability-apm'),
() => initializeRuleDataTemplatesPromise
);
@ -206,7 +207,7 @@ export class APMPlugin
}) as APMRouteHandlerResources['plugins'];
const telemetryUsageCounter = resourcePlugins.usageCollection?.setup.createUsageCounter(
'apm'
APM_SERVER_FEATURE_ID
);
registerRoutes({

View file

@ -14,6 +14,7 @@ import {
} from 'src/core/server';
import { RuleDataClient } from '../../../rule_registry/server';
import { AlertingApiRequestHandlerContext } from '../../../alerting/server';
import type { RacApiRequestHandlerContext } from '../../../rule_registry/server';
import { LicensingApiRequestHandlerContext } from '../../../licensing/server';
import { APMConfig } from '..';
import { APMPluginDependencies } from '../types';
@ -21,6 +22,7 @@ import { APMPluginDependencies } from '../types';
export interface ApmPluginRequestHandlerContext extends RequestHandlerContext {
licensing: LicensingApiRequestHandlerContext;
alerting: AlertingApiRequestHandlerContext;
rac: RacApiRequestHandlerContext;
}
export type InspectResponse = Array<{

View file

@ -15,9 +15,9 @@ import {
SerializerOrUndefined,
Type,
} from '@kbn/securitysolution-io-ts-list-types';
import { encodeHitVersion } from '@kbn/securitysolution-es-utils';
import { transformListItemToElasticQuery } from '../utils';
import { encodeHitVersion } from '../utils/encode_hit_version';
import { IndexEsListItemSchema } from '../../schemas/elastic_query';
export interface CreateListItemOptions {

View file

@ -12,10 +12,9 @@ import type {
MetaOrUndefined,
_VersionOrUndefined,
} from '@kbn/securitysolution-io-ts-list-types';
import { decodeVersion, encodeHitVersion } from '@kbn/securitysolution-es-utils';
import { transformListItemToElasticQuery } from '../utils';
import { decodeVersion } from '../utils/decode_version';
import { encodeHitVersion } from '../utils/encode_hit_version';
import { UpdateEsListItemSchema } from '../../schemas/elastic_query';
import { getListItem } from './get_list_item';

View file

@ -19,8 +19,8 @@ import type {
Type,
} from '@kbn/securitysolution-io-ts-list-types';
import type { Version } from '@kbn/securitysolution-io-ts-types';
import { encodeHitVersion } from '@kbn/securitysolution-es-utils';
import { encodeHitVersion } from '../utils/encode_hit_version';
import { IndexEsListSchema } from '../../schemas/elastic_query';
export interface CreateListOptions {

View file

@ -15,9 +15,8 @@ import type {
_VersionOrUndefined,
} from '@kbn/securitysolution-io-ts-list-types';
import { VersionOrUndefined } from '@kbn/securitysolution-io-ts-types';
import { decodeVersion, encodeHitVersion } from '@kbn/securitysolution-es-utils';
import { decodeVersion } from '../utils/decode_version';
import { encodeHitVersion } from '../utils/encode_hit_version';
import { UpdateEsListSchema } from '../../schemas/elastic_query';
import { getList } from '.';

View file

@ -6,9 +6,7 @@
*/
export * from './calculate_scroll_math';
export * from './decode_version';
export * from './encode_decode_cursor';
export * from './encode_hit_version';
export * from './escape_query';
export * from './find_source_type';
export * from './find_source_value';

View file

@ -7,11 +7,10 @@
import type { estypes } from '@elastic/elasticsearch';
import type { ListArraySchema } from '@kbn/securitysolution-io-ts-list-types';
import { encodeHitVersion } from '@kbn/securitysolution-es-utils';
import { SearchEsListSchema } from '../../schemas/elastic_response';
import { encodeHitVersion } from './encode_hit_version';
export interface TransformElasticToListOptions {
response: estypes.SearchResponse<SearchEsListSchema>;
}

View file

@ -7,11 +7,11 @@
import type { estypes } from '@elastic/elasticsearch';
import type { ListItemArraySchema, Type } from '@kbn/securitysolution-io-ts-list-types';
import { encodeHitVersion } from '@kbn/securitysolution-es-utils';
import { ErrorWithStatusCode } from '../../error_with_status_code';
import { SearchEsListItemSchema } from '../../schemas/elastic_response';
import { encodeHitVersion } from './encode_hit_version';
import { findSourceValue } from './find_source_value';
export interface TransformElasticToListItemOptions {

View file

@ -20,6 +20,7 @@ import type {
ActionsApiRequestHandlerContext,
} from '../../actions/server';
import type { AlertingApiRequestHandlerContext } from '../../alerting/server';
import type { RacApiRequestHandlerContext } from '../../rule_registry/server';
import {
PluginStartContract as AlertingPluginStartContract,
PluginSetupContract as AlertingPluginSetupContract,
@ -57,6 +58,7 @@ export interface RequestHandlerContextMonitoringPlugin extends RequestHandlerCon
actions?: ActionsApiRequestHandlerContext;
alerting?: AlertingApiRequestHandlerContext;
infra: InfraRequestHandlerContext;
ruleRegistry?: RacApiRequestHandlerContext;
}
export interface PluginsStart {

View file

@ -99,6 +99,7 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
const start = () => core.getStartServices().then(([coreStart]) => coreStart);
const ruleDataClient = plugins.ruleRegistry.ruleDataService.getRuleDataClient(
'observability',
plugins.ruleRegistry.ruleDataService.getFullAssetName(),
() => Promise.resolve()
);

View file

@ -27,9 +27,7 @@ On plugin setup, rule type producers can create the index template as follows:
```ts
// get the FQN of the component template. All assets are prefixed with the configured `index` value, which is `.alerts` by default.
const componentTemplateName = plugins.ruleRegistry.getFullAssetName(
'apm-mappings'
);
const componentTemplateName = plugins.ruleRegistry.getFullAssetName('apm-mappings');
// if write is disabled, don't install these templates
if (!plugins.ruleRegistry.isWriteEnabled()) {
@ -73,14 +71,10 @@ await plugins.ruleRegistry.createOrUpdateComponentTemplate({
await plugins.ruleRegistry.createOrUpdateIndexTemplate({
name: plugins.ruleRegistry.getFullAssetName('apm-index-template'),
body: {
index_patterns: [
plugins.ruleRegistry.getFullAssetName('observability-apm*'),
],
index_patterns: [plugins.ruleRegistry.getFullAssetName('observability-apm*')],
composed_of: [
// Technical component template, required
plugins.ruleRegistry.getFullAssetName(
TECHNICAL_COMPONENT_TEMPLATE_NAME
),
plugins.ruleRegistry.getFullAssetName(TECHNICAL_COMPONENT_TEMPLATE_NAME),
componentTemplateName,
],
},
@ -107,8 +101,7 @@ await ruleDataClient.getWriter().bulk({
// to read data, simply call ruleDataClient.getReader().search:
const response = await ruleDataClient.getReader().search({
body: {
query: {
},
query: {},
size: 100,
fields: ['*'],
sort: {
@ -132,6 +125,7 @@ The following fields are defined in the technical field component template and s
- `rule.name`: the name of the rule (as specified by the user).
- `rule.category`: the name of the rule type (as defined by the rule type producer)
- `kibana.rac.alert.producer`: the producer of the rule type. Usually a Kibana plugin. e.g., `APM`.
- `kibana.rac.alert.owner`: the feature which produced the alert. Usually a Kibana feature id like `apm`, `siem`...
- `kibana.rac.alert.id`: the id of the alert, that is unique within the context of the rule execution it was created in. E.g., for a rule that monitors latency for all services in all environments, this might be `opbeans-java:production`.
- `kibana.rac.alert.uuid`: the unique identifier for the alert during its lifespan. If an alert recovers (or closes), this identifier is re-generated when it is opened again.
- `kibana.rac.alert.status`: the status of the alert. Can be `open` or `closed`.
@ -145,3 +139,16 @@ The following fields are defined in the technical field component template and s
- `kibana.rac.alert.ancestors`: the array of ancestors (if any) for the alert.
- `kibana.rac.alert.depth`: the depth of the alert in the ancestral tree (default 0).
- `kibana.rac.alert.building_block_type`: the building block type of the alert (default undefined).
# Alerts as data
Alerts as data can be interacted with using the AlertsClient api found in `x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts`
This api includes public methods such as
[x] getFullAssetName
[x] getAlertsIndex
[x] get
[x] update
[ ] bulkUpdate (TODO)
[ ] find (TODO)

View file

@ -18,6 +18,7 @@ import {
ALERT_UUID,
EVENT_ACTION,
EVENT_KIND,
OWNER,
PRODUCER,
RULE_CATEGORY,
RULE_ID,
@ -40,6 +41,7 @@ export const technicalRuleFieldMap = {
RULE_CATEGORY,
TAGS
),
[OWNER]: { type: 'keyword' },
[PRODUCER]: { type: 'keyword' },
[ALERT_UUID]: { type: 'keyword' },
[ALERT_ID]: { type: 'keyword' },

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export const BASE_RAC_ALERTS_API_PATH = '/internal/rac/alerts';

View file

@ -0,0 +1,44 @@
# Alerts as data Client API Docs
This directory contains generated docs using `typedoc` for the alerts as data client (alerts client) API that can be called from other server
plugins. This README will describe how to generate a new version of these markdown docs in the event that new methods
or parameters are added.
## TypeDoc Info
See more info at: <https://typedoc.org/>
and: <https://www.npmjs.com/package/typedoc-plugin-markdown> for the markdown plugin
## Install dependencies
```bash
yarn global add typedoc typedoc-plugin-markdown
```
## Generate the docs
```bash
cd x-pack/plugins/rule_registry/docs
npx typedoc --options alerts_client_typedoc.json
```
After running the above commands the files in the `server` directory will be updated to match the new tsdocs.
If additional markdown directory should be created we can create a new typedoc configuration file and adjust the `out`
directory accordingly.
## Troubleshooting
This will use the global `tsc` so ensure typescript is installed globally and one of typescript version `3.9, 4.0, 4.1, 4.2`.
```
$ tsc --version
Version 4.2.4
```
If you run into tsc errors that seem unrelated to the cases plugin try executing these commands before running `typedoc`
```bash
cd <kibana root dir>
npx yarn kbn bootstrap
node scripts/build_ts_refs.js --clean --no-cache
```

View file

@ -0,0 +1,14 @@
Alerts as data client API Interface
# Alerts as data client API Interface
## Table of contents
### Classes
- [AlertsClient](classes/alertsclient.md)
### Interfaces
- [ConstructorOptions](interfaces/constructoroptions.md)
- [UpdateOptions](interfaces/updateoptions.md)

View file

@ -0,0 +1,191 @@
[Alerts as data client API Interface](../alerts_client_api.md) / AlertsClient
# Class: AlertsClient
Provides apis to interact with alerts as data
ensures the request is authorized to perform read / write actions
on alerts as data.
## Table of contents
### Constructors
- [constructor](alertsclient.md#constructor)
### Properties
- [auditLogger](alertsclient.md#auditlogger)
- [authorization](alertsclient.md#authorization)
- [esClient](alertsclient.md#esclient)
- [logger](alertsclient.md#logger)
### Methods
- [fetchAlert](alertsclient.md#fetchalert)
- [get](alertsclient.md#get)
- [getAlertsIndex](alertsclient.md#getalertsindex)
- [getAuthorizedAlertsIndices](alertsclient.md#getauthorizedalertsindices)
- [update](alertsclient.md#update)
## Constructors
### constructor
**new AlertsClient**(`__namedParameters`)
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | [ConstructorOptions](../interfaces/constructoroptions.md) |
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:59](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L59)
## Properties
### auditLogger
`Private` `Optional` `Readonly` **auditLogger**: `AuditLogger`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:57](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L57)
___
### authorization
`Private` `Readonly` **authorization**: `PublicMethodsOf`<AlertingAuthorization\>
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:58](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L58)
___
### esClient
`Private` `Readonly` **esClient**: `ElasticsearchClient`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:59](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L59)
___
### logger
`Private` `Readonly` **logger**: `Logger`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:56](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L56)
## Methods
### fetchAlert
`Private` **fetchAlert**(`__namedParameters`): `Promise`<AlertType\>
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | `GetAlertParams` |
#### Returns
`Promise`<AlertType\>
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:79](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L79)
___
### get
**get**(`__namedParameters`): `Promise`<OutputOf<SetOptional<`Object`\>\>\>
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | `GetAlertParams` |
#### Returns
`Promise`<OutputOf<SetOptional<`Object`\>\>\>
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:108](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L108)
___
### getAlertsIndex
**getAlertsIndex**(`featureIds`, `operations`): `Promise`<`Object`\>
#### Parameters
| Name | Type |
| :------ | :------ |
| `featureIds` | `string`[] |
| `operations` | (`ReadOperations` \| `WriteOperations`)[] |
#### Returns
`Promise`<`Object`\>
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:68](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L68)
___
### getAuthorizedAlertsIndices
**getAuthorizedAlertsIndices**(`featureIds`): `Promise`<undefined \| string[]\>
#### Parameters
| Name | Type |
| :------ | :------ |
| `featureIds` | `string`[] |
#### Returns
`Promise`<undefined \| string[]\>
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:200](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L200)
___
### update
**update**<Params\>(`__namedParameters`): `Promise`<`Object`\>
#### Type parameters
| Name | Type |
| :------ | :------ |
| `Params` | `Params`: `AlertTypeParams` = `never` |
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | [UpdateOptions](../interfaces/updateoptions.md)<Params\> |
#### Returns
`Promise`<`Object`\>
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:146](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L146)

View file

@ -0,0 +1,52 @@
[Alerts as data client API Interface](../alerts_client_api.md) / ConstructorOptions
# Interface: ConstructorOptions
## Table of contents
### Properties
- [auditLogger](constructoroptions.md#auditlogger)
- [authorization](constructoroptions.md#authorization)
- [esClient](constructoroptions.md#esclient)
- [logger](constructoroptions.md#logger)
## Properties
### auditLogger
`Optional` **auditLogger**: `AuditLogger`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:34](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L34)
___
### authorization
**authorization**: `PublicMethodsOf`<AlertingAuthorization\>
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:33](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L33)
___
### esClient
**esClient**: `ElasticsearchClient`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:35](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L35)
___
### logger
**logger**: `Logger`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:32](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L32)

View file

@ -0,0 +1,58 @@
[Alerts as data client API Interface](../alerts_client_api.md) / UpdateOptions
# Interface: UpdateOptions<Params\>
## Type parameters
| Name | Type |
| :------ | :------ |
| `Params` | `Params`: `AlertTypeParams` |
## Table of contents
### Properties
- [\_version](updateoptions.md#_version)
- [id](updateoptions.md#id)
- [index](updateoptions.md#index)
- [status](updateoptions.md#status)
## Properties
### \_version
**\_version**: `undefined` \| `string`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:41](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L41)
___
### id
**id**: `string`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:39](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L39)
___
### index
**index**: `string`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:42](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L42)
___
### status
**status**: `string`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:40](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L40)

View file

@ -0,0 +1,17 @@
{
"entryPoints": [
"../server/alert_data_client/alerts_client.ts"
],
"exclude": [
"**/mock.ts",
"../server/alert_data_client/+(mock.ts|utils.ts|utils.test.ts|types.ts)"
],
"excludeExternals": true,
"out": "alerts_client",
"theme": "markdown",
"plugin": "typedoc-plugin-markdown",
"entryDocument": "alerts_client_api.md",
"readme": "none",
"name": "Alerts as data client API Interface"
}

View file

@ -12,5 +12,6 @@
"spaces",
"triggersActionsUi"
],
"optionalPlugins": ["security"],
"server": true
}

View file

@ -0,0 +1,28 @@
/*
* 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 type { PublicMethodsOf } from '@kbn/utility-types';
import { AlertsClient } from './alerts_client';
type Schema = PublicMethodsOf<AlertsClient>;
export type AlertsClientMock = jest.Mocked<Schema>;
const createAlertsClientMock = () => {
const mocked: AlertsClientMock = {
get: jest.fn(),
getAlertsIndex: jest.fn(),
update: jest.fn(),
getAuthorizedAlertsIndices: jest.fn(),
};
return mocked;
};
export const alertsClientMock: {
create: () => AlertsClientMock;
} = {
create: createAlertsClientMock,
};

View file

@ -0,0 +1,243 @@
/*
* 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 { PublicMethodsOf } from '@kbn/utility-types';
import { decodeVersion, encodeHitVersion } from '@kbn/securitysolution-es-utils';
import { AlertTypeParams } from '../../../alerting/server';
import {
ReadOperations,
AlertingAuthorization,
WriteOperations,
AlertingAuthorizationEntity,
} from '../../../alerting/server';
import { Logger, ElasticsearchClient } from '../../../../../src/core/server';
import { alertAuditEvent, AlertAuditAction } from './audit_events';
import { AuditLogger } from '../../../security/server';
import { ALERT_STATUS, OWNER, RULE_ID } from '../../common/technical_rule_data_field_names';
import { ParsedTechnicalFields } from '../../common/parse_technical_fields';
import { mapConsumerToIndexName, validFeatureIds, isValidFeatureId } from '../utils/rbac';
// TODO: Fix typings https://github.com/elastic/kibana/issues/101776
type NonNullableProps<Obj extends {}, Props extends keyof Obj> = Omit<Obj, Props> &
{ [K in Props]-?: NonNullable<Obj[K]> };
type AlertType = NonNullableProps<ParsedTechnicalFields, 'rule.id' | 'kibana.rac.alert.owner'>;
const isValidAlert = (source?: ParsedTechnicalFields): source is AlertType => {
return source?.[RULE_ID] != null && source?.[OWNER] != null;
};
export interface ConstructorOptions {
logger: Logger;
authorization: PublicMethodsOf<AlertingAuthorization>;
auditLogger?: AuditLogger;
esClient: ElasticsearchClient;
}
export interface UpdateOptions<Params extends AlertTypeParams> {
id: string;
status: string;
_version: string | undefined;
index: string;
}
interface GetAlertParams {
id: string;
index?: string;
}
/**
* Provides apis to interact with alerts as data
* ensures the request is authorized to perform read / write actions
* on alerts as data.
*/
export class AlertsClient {
private readonly logger: Logger;
private readonly auditLogger?: AuditLogger;
private readonly authorization: PublicMethodsOf<AlertingAuthorization>;
private readonly esClient: ElasticsearchClient;
constructor({ auditLogger, authorization, logger, esClient }: ConstructorOptions) {
this.logger = logger;
this.authorization = authorization;
this.esClient = esClient;
this.auditLogger = auditLogger;
}
public async getAlertsIndex(
featureIds: string[],
operations: Array<ReadOperations | WriteOperations>
) {
return this.authorization.getAugmentedRuleTypesWithAuthorization(
featureIds.length !== 0 ? featureIds : validFeatureIds,
operations,
AlertingAuthorizationEntity.Alert
);
}
private async fetchAlert({
id,
index,
}: GetAlertParams): Promise<(AlertType & { _version: string | undefined }) | null | undefined> {
try {
const result = await this.esClient.search<ParsedTechnicalFields>({
// Context: Originally thought of always just searching `.alerts-*` but that could
// result in a big performance hit. If the client already knows which index the alert
// belongs to, passing in the index will speed things up
index: index ?? '.alerts-*',
ignore_unavailable: true,
body: { query: { term: { _id: id } } },
seq_no_primary_term: true,
});
if (result == null || result.body == null || result.body.hits.hits.length === 0) {
return;
}
if (!isValidAlert(result.body.hits.hits[0]._source)) {
const errorMessage = `Unable to retrieve alert details for alert with id of "${id}".`;
this.logger.debug(errorMessage);
throw new Error(errorMessage);
}
return {
...result.body.hits.hits[0]._source,
_version: encodeHitVersion(result.body.hits.hits[0]),
};
} catch (error) {
const errorMessage = `Unable to retrieve alert with id of "${id}".`;
this.logger.debug(errorMessage);
throw error;
}
}
public async get({
id,
index,
}: GetAlertParams): Promise<ParsedTechnicalFields | null | undefined> {
try {
// first search for the alert by id, then use the alert info to check if user has access to it
const alert = await this.fetchAlert({
id,
index,
});
if (alert == null) {
return;
}
// this.authorization leverages the alerting plugin's authorization
// client exposed to us for reuse
await this.authorization.ensureAuthorized({
ruleTypeId: alert[RULE_ID],
consumer: alert[OWNER],
operation: ReadOperations.Get,
entity: AlertingAuthorizationEntity.Alert,
});
this.auditLogger?.log(
alertAuditEvent({
action: AlertAuditAction.GET,
id,
})
);
return alert;
} catch (error) {
this.logger.debug(`Error fetching alert with id of "${id}"`);
this.auditLogger?.log(
alertAuditEvent({
action: AlertAuditAction.GET,
id,
error,
})
);
throw error;
}
}
public async update<Params extends AlertTypeParams = never>({
id,
status,
_version,
index,
}: UpdateOptions<Params>) {
try {
const alert = await this.fetchAlert({
id,
index,
});
if (alert == null) {
return;
}
await this.authorization.ensureAuthorized({
ruleTypeId: alert[RULE_ID],
consumer: alert[OWNER],
operation: WriteOperations.Update,
entity: AlertingAuthorizationEntity.Alert,
});
this.auditLogger?.log(
alertAuditEvent({
action: AlertAuditAction.UPDATE,
id,
outcome: 'unknown',
})
);
const { body: response } = await this.esClient.update<ParsedTechnicalFields>({
...decodeVersion(_version),
id,
index,
body: {
doc: {
[ALERT_STATUS]: status,
},
},
refresh: 'wait_for',
});
return {
...response,
_version: encodeHitVersion(response),
};
} catch (error) {
this.auditLogger?.log(
alertAuditEvent({
action: AlertAuditAction.UPDATE,
id,
error,
})
);
throw error;
}
}
public async getAuthorizedAlertsIndices(featureIds: string[]): Promise<string[] | undefined> {
const augmentedRuleTypes = await this.authorization.getAugmentedRuleTypesWithAuthorization(
featureIds,
[ReadOperations.Find, ReadOperations.Get, WriteOperations.Update],
AlertingAuthorizationEntity.Alert
);
// As long as the user can read a minimum of one type of rule type produced by the provided feature,
// the user should be provided that features' alerts index.
// Limiting which alerts that user can read on that index will be done via the findAuthorizationFilter
const authorizedFeatures = new Set<string>();
for (const ruleType of augmentedRuleTypes.authorizedRuleTypes) {
authorizedFeatures.add(ruleType.producer);
}
const toReturn = Array.from(authorizedFeatures).flatMap((feature) => {
if (isValidFeatureId(feature)) {
return mapConsumerToIndexName[feature];
}
return [];
});
return toReturn;
}
}

View file

@ -0,0 +1,78 @@
/*
* 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 { Request } from '@hapi/hapi';
import { AlertsClientFactory, AlertsClientFactoryProps } from './alerts_client_factory';
import { ElasticsearchClient, KibanaRequest } from 'src/core/server';
import { loggingSystemMock } from 'src/core/server/mocks';
import { securityMock } from '../../../security/server/mocks';
import { AuditLogger } from '../../../security/server';
import { alertingAuthorizationMock } from '../../../alerting/server/authorization/alerting_authorization.mock';
jest.mock('./alerts_client');
const securityPluginSetup = securityMock.createSetup();
const alertingAuthMock = alertingAuthorizationMock.create();
const alertsClientFactoryParams: AlertsClientFactoryProps = {
logger: loggingSystemMock.create().get(),
getAlertingAuthorization: (_: KibanaRequest) => alertingAuthMock,
securityPluginSetup,
esClient: {} as ElasticsearchClient,
};
const fakeRequest = ({
app: {},
headers: {},
getBasePath: () => '',
path: '/',
route: { settings: {} },
url: {
href: '/',
},
raw: {
req: {
url: '/',
},
},
} as unknown) as Request;
const auditLogger = {
log: jest.fn(),
} as jest.Mocked<AuditLogger>;
describe('AlertsClientFactory', () => {
beforeEach(() => {
jest.resetAllMocks();
securityPluginSetup.audit.asScoped.mockReturnValue(auditLogger);
});
test('creates an alerts client with proper constructor arguments', async () => {
const factory = new AlertsClientFactory();
factory.initialize({ ...alertsClientFactoryParams });
const request = KibanaRequest.from(fakeRequest);
await factory.create(request);
expect(jest.requireMock('./alerts_client').AlertsClient).toHaveBeenCalledWith({
authorization: alertingAuthMock,
logger: alertsClientFactoryParams.logger,
auditLogger,
esClient: {},
});
});
test('throws an error if already initialized', () => {
const factory = new AlertsClientFactory();
factory.initialize({ ...alertsClientFactoryParams });
expect(() =>
factory.initialize({ ...alertsClientFactoryParams })
).toThrowErrorMatchingInlineSnapshot(`"AlertsClientFactory (RAC) already initialized"`);
});
});

View file

@ -0,0 +1,55 @@
/*
* 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 { ElasticsearchClient, KibanaRequest, Logger } from 'src/core/server';
import { PublicMethodsOf } from '@kbn/utility-types';
import { SecurityPluginSetup } from '../../../security/server';
import { AlertingAuthorization } from '../../../alerting/server';
import { AlertsClient } from './alerts_client';
export interface AlertsClientFactoryProps {
logger: Logger;
esClient: ElasticsearchClient;
getAlertingAuthorization: (request: KibanaRequest) => PublicMethodsOf<AlertingAuthorization>;
securityPluginSetup: SecurityPluginSetup | undefined;
}
export class AlertsClientFactory {
private isInitialized = false;
private logger!: Logger;
private esClient!: ElasticsearchClient;
private getAlertingAuthorization!: (
request: KibanaRequest
) => PublicMethodsOf<AlertingAuthorization>;
private securityPluginSetup!: SecurityPluginSetup | undefined;
public initialize(options: AlertsClientFactoryProps) {
/**
* This should be called by the plugin's start() method.
*/
if (this.isInitialized) {
throw new Error('AlertsClientFactory (RAC) already initialized');
}
this.getAlertingAuthorization = options.getAlertingAuthorization;
this.isInitialized = true;
this.logger = options.logger;
this.esClient = options.esClient;
this.securityPluginSetup = options.securityPluginSetup;
}
public async create(request: KibanaRequest): Promise<AlertsClient> {
const { securityPluginSetup, getAlertingAuthorization, logger } = this;
return new AlertsClient({
logger,
authorization: getAlertingAuthorization(request),
auditLogger: securityPluginSetup?.audit.asScoped(request),
esClient: this.esClient,
});
}
}

View file

@ -0,0 +1,87 @@
/*
* 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 { AlertAuditAction, alertAuditEvent } from './audit_events';
describe('#alertAuditEvent', () => {
test('creates event with `unknown` outcome', () => {
expect(
alertAuditEvent({
action: AlertAuditAction.GET,
outcome: 'unknown',
id: '123',
})
).toMatchInlineSnapshot(`
Object {
"error": undefined,
"event": Object {
"action": "alert_get",
"category": Array [
"database",
],
"outcome": "unknown",
"type": Array [
"access",
],
},
"message": "User is accessing alert [id=123]",
}
`);
});
test('creates event with `success` outcome', () => {
expect(
alertAuditEvent({
action: AlertAuditAction.GET,
id: '123',
})
).toMatchInlineSnapshot(`
Object {
"error": undefined,
"event": Object {
"action": "alert_get",
"category": Array [
"database",
],
"outcome": "success",
"type": Array [
"access",
],
},
"message": "User has accessed alert [id=123]",
}
`);
});
test('creates event with `failure` outcome', () => {
expect(
alertAuditEvent({
action: AlertAuditAction.GET,
id: '123',
error: new Error('ERROR_MESSAGE'),
})
).toMatchInlineSnapshot(`
Object {
"error": Object {
"code": "Error",
"message": "ERROR_MESSAGE",
},
"event": Object {
"action": "alert_get",
"category": Array [
"database",
],
"outcome": "failure",
"type": Array [
"access",
],
},
"message": "Failed attempt to access alert [id=123]",
}
`);
});
});

View file

@ -0,0 +1,61 @@
/*
* 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 { EcsEventOutcome, EcsEventType } from 'src/core/server';
import { AuditEvent } from '../../../security/server';
export enum AlertAuditAction {
GET = 'alert_get',
UPDATE = 'alert_update',
FIND = 'alert_find',
}
type VerbsTuple = [string, string, string];
const eventVerbs: Record<AlertAuditAction, VerbsTuple> = {
alert_get: ['access', 'accessing', 'accessed'],
alert_update: ['update', 'updating', 'updated'],
alert_find: ['access', 'accessing', 'accessed'],
};
const eventTypes: Record<AlertAuditAction, EcsEventType> = {
alert_get: 'access',
alert_update: 'change',
alert_find: 'access',
};
export interface AlertAuditEventParams {
action: AlertAuditAction;
outcome?: EcsEventOutcome;
id?: string;
error?: Error;
}
export function alertAuditEvent({ action, id, outcome, error }: AlertAuditEventParams): AuditEvent {
const doc = id ? `alert [id=${id}]` : 'an alert';
const [present, progressive, past] = eventVerbs[action];
const message = error
? `Failed attempt to ${present} ${doc}`
: outcome === 'unknown'
? `User is ${progressive} ${doc}`
: `User has ${past} ${doc}`;
const type = eventTypes[action];
return {
message,
event: {
action,
category: ['database'],
type: type ? [type] : undefined,
outcome: outcome ?? (error ? 'failure' : 'success'),
},
error: error && {
code: error.name,
message: error.message,
},
};
}

View file

@ -0,0 +1,240 @@
/*
* 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 { AlertsClient, ConstructorOptions } from '../alerts_client';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks';
import { alertingAuthorizationMock } from '../../../../alerting/server/authorization/alerting_authorization.mock';
import { AuditLogger } from '../../../../security/server';
const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
const auditLogger = {
log: jest.fn(),
} as jest.Mocked<AuditLogger>;
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
logger: loggingSystemMock.create().get(),
authorization: alertingAuthMock,
esClient: esClientMock,
auditLogger,
};
beforeEach(() => {
jest.resetAllMocks();
});
describe('get()', () => {
test('calls ES client with given params', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
took: 5,
timed_out: false,
_shards: {
total: 1,
successful: 1,
failed: 0,
skipped: 0,
},
hits: {
total: 1,
max_score: 999,
hits: [
{
found: true,
_type: 'alert',
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 1,
_seq_no: 362,
_primary_term: 2,
_source: {
'rule.id': 'apm.error_rate',
message: 'hello world 1',
'kibana.rac.alert.owner': 'apm',
'kibana.rac.alert.status': 'open',
},
},
],
},
},
})
);
const result = await alertsClient.get({ id: '1', index: '.alerts-observability-apm' });
expect(result).toMatchInlineSnapshot(`
Object {
"_version": "WzM2MiwyXQ==",
"kibana.rac.alert.owner": "apm",
"kibana.rac.alert.status": "open",
"message": "hello world 1",
"rule.id": "apm.error_rate",
}
`);
expect(esClientMock.search).toHaveBeenCalledTimes(1);
expect(esClientMock.search.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"body": Object {
"query": Object {
"term": Object {
"_id": "1",
},
},
},
"ignore_unavailable": true,
"index": ".alerts-observability-apm",
"seq_no_primary_term": true,
},
]
`);
});
test('logs successful event in audit logger', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
took: 5,
timed_out: false,
_shards: {
total: 1,
successful: 1,
failed: 0,
skipped: 0,
},
hits: {
total: 1,
max_score: 999,
hits: [
{
found: true,
_type: 'alert',
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 1,
_seq_no: 362,
_primary_term: 2,
_source: {
'rule.id': 'apm.error_rate',
message: 'hello world 1',
'kibana.rac.alert.owner': 'apm',
'kibana.rac.alert.status': 'open',
},
},
],
},
},
})
);
await alertsClient.get({ id: '1', index: '.alerts-observability-apm' });
expect(auditLogger.log).toHaveBeenCalledWith({
error: undefined,
event: { action: 'alert_get', category: ['database'], outcome: 'success', type: ['access'] },
message: 'User has accessed alert [id=1]',
});
});
test(`throws an error if ES client get fails`, async () => {
const error = new Error('something went wrong');
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockRejectedValue(error);
await expect(
alertsClient.get({ id: '1', index: '.alerts-observability-apm' })
).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong"`);
expect(auditLogger.log).toHaveBeenCalledWith({
error: { code: 'Error', message: 'something went wrong' },
event: { action: 'alert_get', category: ['database'], outcome: 'failure', type: ['access'] },
message: 'Failed attempt to access alert [id=1]',
});
});
describe('authorization', () => {
beforeEach(() => {
esClientMock.search.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
took: 5,
timed_out: false,
_shards: {
total: 1,
successful: 1,
failed: 0,
skipped: 0,
},
hits: {
total: 1,
max_score: 999,
hits: [
{
found: true,
_type: 'alert',
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 1,
_seq_no: 362,
_primary_term: 2,
_source: {
'rule.id': 'apm.error_rate',
message: 'hello world 1',
'kibana.rac.alert.owner': 'apm',
'kibana.rac.alert.status': 'open',
},
},
],
},
},
})
);
});
test('returns alert if user is authorized to read alert under the consumer', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
const result = await alertsClient.get({ id: '1', index: '.alerts-observability-apm' });
expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({
entity: 'alert',
consumer: 'apm',
operation: 'get',
ruleTypeId: 'apm.error_rate',
});
expect(result).toMatchInlineSnapshot(`
Object {
"_version": "WzM2MiwyXQ==",
"kibana.rac.alert.owner": "apm",
"kibana.rac.alert.status": "open",
"message": "hello world 1",
"rule.id": "apm.error_rate",
}
`);
});
test('throws when user is not authorized to get this type of alert', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
alertingAuthMock.ensureAuthorized.mockRejectedValue(
new Error(`Unauthorized to get a "apm.error_rate" alert for "apm"`)
);
await expect(
alertsClient.get({ id: '1', index: '.alerts-observability-apm' })
).rejects.toMatchInlineSnapshot(
`[Error: Unauthorized to get a "apm.error_rate" alert for "apm"]`
);
expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({
entity: 'alert',
consumer: 'apm',
operation: 'get',
ruleTypeId: 'apm.error_rate',
});
});
});
});

View file

@ -0,0 +1,376 @@
/*
* 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 { AlertsClient, ConstructorOptions } from '../alerts_client';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks';
import { alertingAuthorizationMock } from '../../../../alerting/server/authorization/alerting_authorization.mock';
import { AuditLogger } from '../../../../security/server';
const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
const auditLogger = {
log: jest.fn(),
} as jest.Mocked<AuditLogger>;
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
logger: loggingSystemMock.create().get(),
authorization: alertingAuthMock,
esClient: esClientMock,
auditLogger,
};
beforeEach(() => {
jest.resetAllMocks();
});
describe('update()', () => {
test('calls ES client with given params', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
took: 5,
timed_out: false,
_shards: {
total: 1,
successful: 1,
failed: 0,
skipped: 0,
},
hits: {
total: 1,
max_score: 999,
hits: [
{
found: true,
_type: 'alert',
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_source: {
'rule.id': 'apm.error_rate',
message: 'hello world 1',
'kibana.rac.alert.owner': 'apm',
'kibana.rac.alert.status': 'open',
},
},
],
},
},
})
);
esClientMock.update.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 2,
result: 'updated',
_shards: { total: 2, successful: 1, failed: 0 },
_seq_no: 1,
_primary_term: 1,
},
})
);
const result = await alertsClient.update({
id: '1',
status: 'closed',
_version: undefined,
index: '.alerts-observability-apm',
});
expect(result).toMatchInlineSnapshot(`
Object {
"_id": "NoxgpHkBqbdrfX07MqXV",
"_index": ".alerts-observability-apm",
"_primary_term": 1,
"_seq_no": 1,
"_shards": Object {
"failed": 0,
"successful": 1,
"total": 2,
},
"_version": "WzEsMV0=",
"result": "updated",
}
`);
expect(esClientMock.update).toHaveBeenCalledTimes(1);
expect(esClientMock.update.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"body": Object {
"doc": Object {
"kibana.rac.alert.status": "closed",
},
},
"id": "1",
"index": ".alerts-observability-apm",
"refresh": "wait_for",
},
]
`);
});
test('logs successful event in audit logger', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
took: 5,
timed_out: false,
_shards: {
total: 1,
successful: 1,
failed: 0,
skipped: 0,
},
hits: {
total: 1,
max_score: 999,
hits: [
{
found: true,
_type: 'alert',
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_source: {
'rule.id': 'apm.error_rate',
message: 'hello world 1',
'kibana.rac.alert.owner': 'apm',
'kibana.rac.alert.status': 'open',
},
},
],
},
},
})
);
esClientMock.update.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 2,
result: 'updated',
_shards: { total: 2, successful: 1, failed: 0 },
_seq_no: 1,
_primary_term: 1,
},
})
);
await alertsClient.update({
id: '1',
status: 'closed',
_version: undefined,
index: '.alerts-observability-apm',
});
expect(auditLogger.log).toHaveBeenCalledWith({
error: undefined,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'unknown',
type: ['change'],
},
message: 'User is updating alert [id=1]',
});
});
test(`throws an error if ES client get fails`, async () => {
const error = new Error('something went wrong on get');
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockRejectedValue(error);
await expect(
alertsClient.update({
id: '1',
status: 'closed',
_version: undefined,
index: '.alerts-observability-apm',
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong on get"`);
expect(auditLogger.log).toHaveBeenCalledWith({
error: { code: 'Error', message: 'something went wrong on get' },
event: {
action: 'alert_update',
category: ['database'],
outcome: 'failure',
type: ['change'],
},
message: 'Failed attempt to update alert [id=1]',
});
});
test(`throws an error if ES client update fails`, async () => {
const error = new Error('something went wrong on update');
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
took: 5,
timed_out: false,
_shards: {
total: 1,
successful: 1,
failed: 0,
skipped: 0,
},
hits: {
total: 1,
max_score: 999,
hits: [
{
found: true,
_type: 'alert',
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_source: {
'rule.id': 'apm.error_rate',
message: 'hello world 1',
'kibana.rac.alert.owner': 'apm',
'kibana.rac.alert.status': 'open',
},
},
],
},
},
})
);
esClientMock.update.mockRejectedValue(error);
await expect(
alertsClient.update({
id: '1',
status: 'closed',
_version: undefined,
index: '.alerts-observability-apm',
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong on update"`);
expect(auditLogger.log).toHaveBeenCalledWith({
error: { code: 'Error', message: 'something went wrong on update' },
event: {
action: 'alert_update',
category: ['database'],
outcome: 'failure',
type: ['change'],
},
message: 'Failed attempt to update alert [id=1]',
});
});
describe('authorization', () => {
beforeEach(() => {
esClientMock.search.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
took: 5,
timed_out: false,
_shards: {
total: 1,
successful: 1,
failed: 0,
skipped: 0,
},
hits: {
total: 1,
max_score: 999,
hits: [
{
found: true,
_type: 'alert',
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 2,
_seq_no: 362,
_primary_term: 2,
_source: {
'rule.id': 'apm.error_rate',
message: 'hello world 1',
'kibana.rac.alert.owner': 'apm',
'kibana.rac.alert.status': 'open',
},
},
],
},
},
})
);
esClientMock.update.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 2,
result: 'updated',
_shards: { total: 2, successful: 1, failed: 0 },
_seq_no: 1,
_primary_term: 1,
},
})
);
});
test('returns alert if user is authorized to update alert under the consumer', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
const result = await alertsClient.update({
id: '1',
status: 'closed',
_version: undefined,
index: '.alerts-observability-apm',
});
expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({
entity: 'alert',
consumer: 'apm',
operation: 'update',
ruleTypeId: 'apm.error_rate',
});
expect(result).toMatchInlineSnapshot(`
Object {
"_id": "NoxgpHkBqbdrfX07MqXV",
"_index": ".alerts-observability-apm",
"_primary_term": 1,
"_seq_no": 1,
"_shards": Object {
"failed": 0,
"successful": 1,
"total": 2,
},
"_version": "WzEsMV0=",
"result": "updated",
}
`);
});
test('throws when user is not authorized to update this type of alert', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
alertingAuthMock.ensureAuthorized.mockRejectedValue(
new Error(`Unauthorized to get a "apm.error_rate" alert for "apm"`)
);
await expect(
alertsClient.update({
id: '1',
status: 'closed',
_version: undefined,
index: '.alerts-observability-apm',
})
).rejects.toMatchInlineSnapshot(
`[Error: Unauthorized to get a "apm.error_rate" alert for "apm"]`
);
expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({
entity: 'alert',
consumer: 'apm',
operation: 'update',
ruleTypeId: 'apm.error_rate',
});
});
});
});

View file

@ -72,7 +72,7 @@ export class IndexWriter {
for (const item of items) {
if (item.doc === undefined) continue;
bulkBody.push({ create: { _index: item.index } });
bulkBody.push({ create: { _index: item.index, version: 1 } });
bulkBody.push(item.doc);
}

View file

@ -10,6 +10,7 @@ import { RuleRegistryPlugin } from './plugin';
export * from './config';
export type { RuleRegistryPluginSetupContract, RuleRegistryPluginStartContract } from './plugin';
export type { RacRequestHandlerContext, RacApiRequestHandlerContext } from './types';
export { RuleDataClient } from './rule_data_client';
export { IRuleDataClient } from './rule_data_client/types';
export { getRuleExecutorData, RuleExecutorData } from './utils/get_rule_executor_data';

View file

@ -4,19 +4,34 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PluginInitializerContext, Plugin, CoreSetup, Logger } from 'src/core/server';
import {
PluginInitializerContext,
Plugin,
CoreSetup,
Logger,
KibanaRequest,
CoreStart,
IContextProvider,
} from 'src/core/server';
import { SecurityPluginSetup } from '../../security/server';
import { AlertsClientFactory } from './alert_data_client/alerts_client_factory';
import { PluginStartContract as AlertingStart } from '../../alerting/server';
import { RacApiRequestHandlerContext, RacRequestHandlerContext } from './types';
import { defineRoutes } from './routes';
import { SpacesPluginStart } from '../../spaces/server';
import { RuleRegistryPluginConfig } from './config';
import { RuleDataPluginService } from './rule_data_plugin_service';
import { EventLogService, IEventLogService } from './event_log';
import { AlertsClient } from './alert_data_client/alerts_client';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface RuleRegistryPluginSetupDependencies {}
export interface RuleRegistryPluginSetupDependencies {
security?: SecurityPluginSetup;
}
interface RuleRegistryPluginStartDependencies {
export interface RuleRegistryPluginStartDependencies {
spaces: SpacesPluginStart;
alerting: AlertingStart;
}
export interface RuleRegistryPluginSetupContract {
@ -24,7 +39,10 @@ export interface RuleRegistryPluginSetupContract {
eventLogService: IEventLogService;
}
export type RuleRegistryPluginStartContract = void;
export interface RuleRegistryPluginStartContract {
getRacClientWithRequest: (req: KibanaRequest) => Promise<AlertsClient>;
alerting: AlertingStart;
}
export class RuleRegistryPlugin
implements
@ -37,17 +55,23 @@ export class RuleRegistryPlugin
private readonly config: RuleRegistryPluginConfig;
private readonly logger: Logger;
private eventLogService: EventLogService | null;
private readonly alertsClientFactory: AlertsClientFactory;
private ruleDataService: RuleDataPluginService | null;
private security: SecurityPluginSetup | undefined;
constructor(initContext: PluginInitializerContext) {
this.config = initContext.config.get<RuleRegistryPluginConfig>();
this.logger = initContext.logger.get();
this.eventLogService = null;
this.ruleDataService = null;
this.alertsClientFactory = new AlertsClientFactory();
}
public setup(
core: CoreSetup<RuleRegistryPluginStartDependencies, RuleRegistryPluginStartContract>
core: CoreSetup<RuleRegistryPluginStartDependencies, RuleRegistryPluginStartContract>,
plugins: RuleRegistryPluginSetupDependencies
): RuleRegistryPluginSetupContract {
const { config, logger } = this;
const { logger } = this;
const startDependencies = core.getStartServices().then(([coreStart, pluginStart]) => {
return {
@ -56,23 +80,36 @@ export class RuleRegistryPlugin
};
});
const ruleDataService = new RuleDataPluginService({
logger,
isWriteEnabled: config.write.enabled,
index: config.index,
this.security = plugins.security;
const service = new RuleDataPluginService({
logger: this.logger,
isWriteEnabled: this.config.write.enabled,
index: this.config.index,
getClusterClient: async () => {
const deps = await startDependencies;
return deps.core.elasticsearch.client.asInternalUser;
},
});
ruleDataService.init().catch((originalError) => {
service.init().catch((originalError) => {
const error = new Error('Failed installing assets');
// @ts-ignore
error.stack = originalError.stack;
logger.error(error);
this.logger.error(error);
});
this.ruleDataService = service;
// ALERTS ROUTES
const router = core.http.createRouter<RacRequestHandlerContext>();
core.http.registerRouteHandlerContext<RacRequestHandlerContext, 'rac'>(
'rac',
this.createRouteHandlerContext()
);
defineRoutes(router);
const eventLogService = new EventLogService({
config: {
indexPrefix: this.config.index,
@ -86,10 +123,47 @@ export class RuleRegistryPlugin
});
this.eventLogService = eventLogService;
return { ruleDataService, eventLogService };
return { ruleDataService: this.ruleDataService, eventLogService };
}
public start(): RuleRegistryPluginStartContract {}
public start(
core: CoreStart,
plugins: RuleRegistryPluginStartDependencies
): RuleRegistryPluginStartContract {
const { logger, alertsClientFactory, security } = this;
alertsClientFactory.initialize({
logger,
esClient: core.elasticsearch.client.asInternalUser,
// NOTE: Alerts share the authorization client with the alerting plugin
getAlertingAuthorization(request: KibanaRequest) {
return plugins.alerting.getAlertingAuthorizationWithRequest(request);
},
securityPluginSetup: security,
});
const getRacClientWithRequest = (request: KibanaRequest) => {
return alertsClientFactory.create(request);
};
return {
getRacClientWithRequest,
alerting: plugins.alerting,
};
}
private createRouteHandlerContext = (): IContextProvider<RacRequestHandlerContext, 'rac'> => {
const { alertsClientFactory } = this;
return function alertsRouteHandlerContext(context, request): RacApiRequestHandlerContext {
return {
getAlertsClient: async () => {
const createdClient = alertsClientFactory.create(request);
return createdClient;
},
};
};
};
public stop() {
const { eventLogService, logger } = this;

View file

@ -0,0 +1,48 @@
/*
* 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 { coreMock, elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks';
import { alertsClientMock } from '../../alert_data_client/alerts_client.mock';
import { RacRequestHandlerContext } from '../../types';
const createMockClients = () => ({
rac: alertsClientMock.create(),
clusterClient: elasticsearchServiceMock.createLegacyScopedClusterClient(),
newClusterClient: elasticsearchServiceMock.createScopedClusterClient(),
savedObjectsClient: savedObjectsClientMock.create(),
});
const createRequestContextMock = (
clients: ReturnType<typeof createMockClients> = createMockClients()
) => {
const coreContext = coreMock.createRequestHandlerContext();
return ({
rac: { getAlertsClient: jest.fn(() => clients.rac) },
core: {
...coreContext,
elasticsearch: {
...coreContext.elasticsearch,
client: clients.newClusterClient,
legacy: { ...coreContext.elasticsearch.legacy, client: clients.clusterClient },
},
savedObjects: { client: clients.savedObjectsClient },
},
} as unknown) as RacRequestHandlerContext;
};
const createTools = () => {
const clients = createMockClients();
const context = createRequestContextMock(clients);
return { clients, context };
};
export const requestContextMock = {
create: createRequestContextMock,
createMockClients,
createTools,
};

View file

@ -0,0 +1,27 @@
/*
* 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 { requestMock } from './server';
export const getReadRequest = () =>
requestMock.create({
method: 'get',
path: BASE_RAC_ALERTS_API_PATH,
query: { id: 'alert-1' },
});
export const getUpdateRequest = () =>
requestMock.create({
method: 'patch',
path: BASE_RAC_ALERTS_API_PATH,
body: {
status: 'closed',
ids: ['alert-1'],
index: '.alerts-observability-apm*',
},
});

View file

@ -0,0 +1,63 @@
/*
* 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 { httpServerMock } from 'src/core/server/mocks';
const responseMock = {
create: httpServerMock.createResponseFactory,
};
type ResponseMock = ReturnType<typeof responseMock.create>;
type Method = keyof ResponseMock;
type MockCall = any;
interface ResponseCall {
body: any;
status: number;
}
/**
* @internal
*/
export interface Response extends ResponseCall {
calls: ResponseCall[];
}
const buildResponses = (method: Method, calls: MockCall[]): ResponseCall[] => {
if (!calls.length) return [];
switch (method) {
case 'ok':
return calls.map(([call]) => ({ status: 200, body: call.body }));
case 'customError':
return calls.map(([call]) => ({
status: call.statusCode,
body: call.body,
}));
default:
throw new Error(`Encountered unexpected call to response.${method}`);
}
};
export const responseAdapter = (response: ResponseMock): Response => {
const methods = Object.keys(response) as Method[];
const calls = methods
.reduce<Response['calls']>((responses, method) => {
const methodMock = response[method];
return [...responses, ...buildResponses(method, methodMock.mock.calls)];
}, [])
.sort((call, other) => other.status - call.status);
const [{ body, status }] = calls;
return {
body,
status,
calls,
};
};

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RequestHandler, RouteConfig, KibanaRequest } from 'src/core/server';
import { httpServerMock, httpServiceMock } from 'src/core/server/mocks';
import { RacRequestHandlerContext } from '../../types';
import { requestContextMock } from './request_context';
import { responseAdapter } from './response_adapters';
export const requestMock = {
create: httpServerMock.createKibanaRequest,
};
export const responseFactoryMock = {
create: httpServerMock.createResponseFactory,
};
interface Route {
config: RouteConfig<unknown, unknown, unknown, 'get' | 'post' | 'delete' | 'patch' | 'put'>;
handler: RequestHandler;
}
const getRoute = (routerMock: MockServer['router']): Route => {
const routeCalls = [
...routerMock.get.mock.calls,
...routerMock.post.mock.calls,
...routerMock.put.mock.calls,
...routerMock.patch.mock.calls,
...routerMock.delete.mock.calls,
];
const [route] = routeCalls;
if (!route) {
throw new Error('No route registered!');
}
const [config, handler] = route;
return { config, handler };
};
const buildResultMock = () => ({ ok: jest.fn((x) => x), badRequest: jest.fn((x) => x) });
class MockServer {
constructor(
public readonly router = httpServiceMock.createRouter(),
private responseMock = responseFactoryMock.create(),
private contextMock = requestContextMock.create(),
private resultMock = buildResultMock()
) {}
public validate(request: KibanaRequest) {
this.validateRequest(request);
return this.resultMock;
}
public async inject(
request: KibanaRequest,
context: RacRequestHandlerContext = this.contextMock
) {
const validatedRequest = this.validateRequest(request);
const [rejection] = this.resultMock.badRequest.mock.calls;
if (rejection) {
throw new Error(`Request was rejected with message: '${rejection}'`);
}
await this.getRoute().handler(context, validatedRequest, this.responseMock);
return responseAdapter(this.responseMock);
}
private getRoute(): Route {
return getRoute(this.router);
}
private maybeValidate(part: any, validator?: any): any {
return typeof validator === 'function' ? validator(part, this.resultMock) : part;
}
private validateRequest(request: KibanaRequest): KibanaRequest {
const validations = this.getRoute().config.validate;
if (!validations) {
return request;
}
const validatedRequest = requestMock.create({
path: request.route.path,
method: request.route.method,
body: this.maybeValidate(request.body, validations.body),
query: this.maybeValidate(request.query, validations.query),
params: this.maybeValidate(request.params, validations.params),
});
return validatedRequest;
}
}
const createMockServer = () => new MockServer();
export const serverMock = {
create: createMockServer,
};

View file

@ -0,0 +1,98 @@
/*
* 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 { ParsedTechnicalFields } from '../../common/parse_technical_fields';
import { getAlertByIdRoute } from './get_alert_by_id';
import { requestContextMock } from './__mocks__/request_context';
import { getReadRequest } from './__mocks__/request_responses';
import { requestMock, serverMock } from './__mocks__/server';
const getMockAlert = (): ParsedTechnicalFields => ({
'@timestamp': '2021-06-21T21:33:05.713Z',
'rule.id': 'apm.error_rate',
'kibana.rac.alert.owner': 'apm',
'kibana.rac.alert.status': 'open',
});
describe('getAlertByIdRoute', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
beforeEach(async () => {
server = serverMock.create();
({ clients, context } = requestContextMock.createTools());
clients.rac.get.mockResolvedValue(getMockAlert());
getAlertByIdRoute(server.router);
});
test('returns 200 when finding a single alert with valid params', async () => {
const response = await server.inject(getReadRequest(), context);
expect(response.status).toEqual(200);
expect(response.body).toEqual(getMockAlert());
});
test('returns 200 when finding a single alert with index param', async () => {
const response = await server.inject(
requestMock.create({
method: 'get',
path: BASE_RAC_ALERTS_API_PATH,
query: { id: 'alert-1', index: '.alerts-me' },
}),
context
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(getMockAlert());
});
describe('request validation', () => {
test('rejects invalid query params', async () => {
await expect(
server.inject(
requestMock.create({
method: 'get',
path: BASE_RAC_ALERTS_API_PATH,
query: { id: 4 },
}),
context
)
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Request was rejected with message: 'Invalid value \\"4\\" supplied to \\"id\\"'"`
);
});
test('rejects unknown query params', async () => {
await expect(
server.inject(
requestMock.create({
method: 'get',
path: BASE_RAC_ALERTS_API_PATH,
query: { notId: 4 },
}),
context
)
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Request was rejected with message: 'Invalid value \\"undefined\\" supplied to \\"id\\"'"`
);
});
});
test('returns error status if rac client "GET" fails', async () => {
clients.rac.get.mockRejectedValue(new Error('Unable to get alert'));
const response = await server.inject(getReadRequest(), context);
expect(response.status).toEqual(500);
expect(response.body).toEqual({
attributes: { success: false },
message: 'Unable to get alert',
});
});
});

View file

@ -0,0 +1,76 @@
/*
* 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 'kibana/server';
import * as t from 'io-ts';
import { id as _id } from '@kbn/securitysolution-io-ts-list-types';
import { transformError } from '@kbn/securitysolution-es-utils';
import { RacRequestHandlerContext } from '../types';
import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants';
import { buildRouteValidation } from './utils/route_validation';
export const getAlertByIdRoute = (router: IRouter<RacRequestHandlerContext>) => {
router.get(
{
path: BASE_RAC_ALERTS_API_PATH,
validate: {
query: buildRouteValidation(
t.intersection([
t.exact(
t.type({
id: _id,
})
),
t.exact(
t.partial({
index: t.string,
})
),
])
),
},
options: {
tags: ['access:rac'],
},
},
async (context, request, response) => {
try {
const alertsClient = await context.rac.getAlertsClient();
const { id, index } = request.query;
const alert = await alertsClient.get({ id, index });
if (alert == null) {
return response.notFound({
body: { message: `alert with id ${id} and index ${index} not found` },
});
}
return response.ok({
body: alert,
});
} catch (exc) {
const err = transformError(exc);
const contentType = {
'content-type': 'application/json',
};
const defaultedHeaders = {
...contentType,
};
return response.customError({
headers: defaultedHeaders,
statusCode: err.statusCode,
body: {
message: err.message,
attributes: {
success: false,
},
},
});
}
}
);
};

View file

@ -0,0 +1,54 @@
/*
* 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 'kibana/server';
import { id as _id } from '@kbn/securitysolution-io-ts-list-types';
import { transformError } from '@kbn/securitysolution-es-utils';
import { RacRequestHandlerContext } from '../types';
import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants';
import { validFeatureIds } from '../utils/rbac';
export const getAlertsIndexRoute = (router: IRouter<RacRequestHandlerContext>) => {
router.get(
{
path: `${BASE_RAC_ALERTS_API_PATH}/index`,
validate: false,
options: {
tags: ['access:rac'],
},
},
async (context, request, response) => {
try {
const alertsClient = await context.rac.getAlertsClient();
const indexName = await alertsClient.getAuthorizedAlertsIndices(validFeatureIds);
return response.ok({
body: { index_name: indexName },
});
} catch (exc) {
const err = transformError(exc);
const contentType = {
'content-type': 'application/json',
};
const defaultedHeaders = {
...contentType,
};
return response.custom({
headers: defaultedHeaders,
statusCode: err.statusCode,
body: Buffer.from(
JSON.stringify({
message: err.message,
status_code: err.statusCode,
})
),
});
}
}
);
};

View file

@ -0,0 +1,18 @@
/*
* 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 'kibana/server';
import { RacRequestHandlerContext } from '../types';
import { getAlertByIdRoute } from './get_alert_by_id';
import { updateAlertByIdRoute } from './update_alert_by_id';
import { getAlertsIndexRoute } from './get_alert_index';
export function defineRoutes(router: IRouter<RacRequestHandlerContext>) {
getAlertByIdRoute(router);
updateAlertByIdRoute(router);
getAlertsIndexRoute(router);
}

View file

@ -0,0 +1,101 @@
/*
* 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 { updateAlertByIdRoute } from './update_alert_by_id';
import { requestContextMock } from './__mocks__/request_context';
import { getUpdateRequest } from './__mocks__/request_responses';
import { requestMock, serverMock } from './__mocks__/server';
describe('updateAlertByIdRoute', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
beforeEach(async () => {
server = serverMock.create();
({ clients, context } = requestContextMock.createTools());
clients.rac.update.mockResolvedValue({
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 'WzM2MiwyXQ==',
result: 'updated',
_shards: { total: 2, successful: 1, failed: 0 },
_seq_no: 1,
_primary_term: 1,
});
updateAlertByIdRoute(server.router);
});
test('returns 200 when updating a single alert with valid params', async () => {
const response = await server.inject(getUpdateRequest(), context);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 'WzM2MiwyXQ==',
result: 'updated',
_shards: { total: 2, successful: 1, failed: 0 },
_seq_no: 1,
_primary_term: 1,
success: true,
});
});
describe('request validation', () => {
test('rejects invalid query params', async () => {
await expect(
server.inject(
requestMock.create({
method: 'patch',
path: BASE_RAC_ALERTS_API_PATH,
body: {
status: 'closed',
ids: 'alert-1',
index: '.alerts-observability-apm*',
},
}),
context
)
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Request was rejected with message: 'Invalid value \\"alert-1\\" supplied to \\"ids\\"'"`
);
});
test('rejects unknown query params', async () => {
await expect(
server.inject(
requestMock.create({
method: 'patch',
path: BASE_RAC_ALERTS_API_PATH,
body: {
notStatus: 'closed',
ids: ['alert-1'],
index: '.alerts-observability-apm*',
},
}),
context
)
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Request was rejected with message: 'Invalid value \\"undefined\\" supplied to \\"status\\"'"`
);
});
});
test('returns error status if rac client "GET" fails', async () => {
clients.rac.update.mockRejectedValue(new Error('Unable to update alert'));
const response = await server.inject(getUpdateRequest(), context);
expect(response.status).toEqual(500);
expect(response.body).toEqual({
attributes: { success: false },
message: 'Unable to update alert',
});
});
});

View file

@ -0,0 +1,85 @@
/*
* 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 'kibana/server';
import * as t from 'io-ts';
import { id as _id } from '@kbn/securitysolution-io-ts-list-types';
import { transformError } from '@kbn/securitysolution-es-utils';
import { buildRouteValidation } from './utils/route_validation';
import { RacRequestHandlerContext } from '../types';
import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants';
export const updateAlertByIdRoute = (router: IRouter<RacRequestHandlerContext>) => {
router.post(
{
path: BASE_RAC_ALERTS_API_PATH,
validate: {
body: buildRouteValidation(
t.intersection([
t.exact(
t.type({
status: t.string,
ids: t.array(t.string),
index: t.string,
})
),
t.exact(
t.partial({
_version: t.string,
})
),
])
),
},
options: {
tags: ['access:rac'],
},
},
async (context, req, response) => {
try {
const alertsClient = await context.rac.getAlertsClient();
const { status, ids, index, _version } = req.body;
const updatedAlert = await alertsClient.update({
id: ids[0],
status,
_version,
index,
});
if (updatedAlert == null) {
return response.notFound({
body: { message: `alerts with ids ${ids} and index ${index} not found` },
});
}
return response.ok({ body: { success: true, ...updatedAlert } });
} catch (exc) {
const err = transformError(exc);
const contentType = {
'content-type': 'application/json',
};
const defaultedHeaders = {
...contentType,
};
return response.customError({
headers: defaultedHeaders,
statusCode: err.statusCode,
body: {
message: err.message,
attributes: {
success: false,
},
},
});
}
}
);
};

View file

@ -0,0 +1,56 @@
/*
* 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.
*/
/*
* 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 { fold } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import * as rt from 'io-ts';
import { exactCheck, formatErrors } from '@kbn/securitysolution-io-ts-utils';
import {
RouteValidationError,
RouteValidationFunction,
RouteValidationResultFactory,
} from '../../../../../../src/core/server';
type RequestValidationResult<T> =
| {
value: T;
error?: undefined;
}
| {
value?: undefined;
error: RouteValidationError;
};
/**
* Copied from x-pack/plugins/security_solution/server/utils/build_validation/route_validation.ts
* This really should be in @kbn/securitysolution-io-ts-utils rather than copied yet again, however, this has types
* from a lot of places such as RouteValidationResultFactory from core/server which in turn can pull in @kbn/schema
* which cannot work on the front end and @kbn/securitysolution-io-ts-utils works on both front and backend.
*
* TODO: Figure out a way to move this function into a package rather than copying it/forking it within plugins
*/
export const buildRouteValidation = <T extends rt.Mixed, A = rt.TypeOf<T>>(
schema: T
): RouteValidationFunction<A> => (
inputValue: unknown,
validationResult: RouteValidationResultFactory
): RequestValidationResult<A> =>
pipe(
schema.decode(inputValue),
(decoded) => exactCheck(inputValue, decoded),
fold<rt.Errors, A, RequestValidationResult<A>>(
(errors: rt.Errors) => validationResult.badRequest(formatErrors(errors).join()),
(validatedInput: A) => validationResult.ok(validatedInput)
)
);

View file

@ -11,6 +11,7 @@ import { ElasticsearchClient } from 'kibana/server';
import { FieldDescriptor } from 'src/plugins/data/server';
import { ESSearchRequest, ESSearchResponse } from 'src/core/types/elasticsearch';
import { TechnicalRuleDataFieldName } from '../../common/technical_rule_data_field_names';
import { ValidFeatureId } from '../utils/rbac';
export interface RuleDataReader {
search<TSearchRequest extends ESSearchRequest>(
@ -37,9 +38,17 @@ export interface IRuleDataClient {
createWriteTargetIfNeeded(options: { namespace?: string }): Promise<void>;
}
/**
* The purpose of the `feature` param is to force the user to update
* the data structure which contains the mapping of consumers to alerts
* as data indices. The idea is it is typed such that it forces the
* user to go to the code and modify it. At least until a better system
* is put in place or we move the alerts as data client out of rule registry.
*/
export interface RuleDataClientConstructorOptions {
getClusterClient: () => Promise<ElasticsearchClient>;
isWriteEnabled: boolean;
ready: () => Promise<void>;
alias: string;
feature: ValidFeatureId;
}

View file

@ -20,10 +20,11 @@ import { ClusterPutComponentTemplateBody, PutIndexTemplateRequest } from '../../
import { RuleDataClient } from '../rule_data_client';
import { RuleDataWriteDisabledError } from './errors';
import { incrementIndexName } from './utils';
import { ValidFeatureId } from '../utils/rbac';
const BOOTSTRAP_TIMEOUT = 60000;
interface RuleDataPluginServiceConstructorOptions {
export interface RuleDataPluginServiceConstructorOptions {
getClusterClient: () => Promise<ElasticsearchClient>;
logger: Logger;
isWriteEnabled: boolean;
@ -223,9 +224,10 @@ export class RuleDataPluginService {
return [this.options.index, assetName].filter(Boolean).join('-');
}
getRuleDataClient(alias: string, initialize: () => Promise<void>) {
getRuleDataClient(feature: ValidFeatureId, alias: string, initialize: () => Promise<void>) {
return new RuleDataClient({
alias,
feature,
getClusterClient: () => this.getClusterClient(),
isWriteEnabled: this.isWriteEnabled(),
ready: initialize,

View file

@ -0,0 +1,35 @@
/*
* 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 type { PublicMethodsOf } from '@kbn/utility-types';
import { RuleDataPluginService, RuleDataPluginServiceConstructorOptions } from './';
type Schema = PublicMethodsOf<RuleDataPluginService>;
const createRuleDataPluginServiceMock = (_: RuleDataPluginServiceConstructorOptions) => {
const mocked: jest.Mocked<Schema> = {
init: jest.fn(),
isReady: jest.fn(),
wait: jest.fn(),
isWriteEnabled: jest.fn(),
getFullAssetName: jest.fn(),
createOrUpdateComponentTemplate: jest.fn(),
createOrUpdateIndexTemplate: jest.fn(),
createOrUpdateLifecyclePolicy: jest.fn(),
getRuleDataClient: jest.fn(),
updateIndexMappingsMatchingPattern: jest.fn(),
};
return mocked;
};
export const ruleDataPluginServiceMock: {
create: (
_: RuleDataPluginServiceConstructorOptions
) => jest.Mocked<PublicMethodsOf<RuleDataPluginService>>;
} = {
create: createRuleDataPluginServiceMock,
};

View file

@ -0,0 +1,24 @@
Users with roles granting them access to monitoring (observability) and siem (security solution) should only be able to access alerts with those roles
```bash
myterminal~$ ./get_security_solution_alert.sh observer
{
"statusCode": 404,
"error": "Not Found",
"message": "Unauthorized to get \"rac:8.0.0:securitySolution/get\" alert\""
}
myterminal~$ ./get_security_solution_alert.sh
{
"success": true
}
myterminal~$ ./get_observability_alert.sh
{
"success": true
}
myterminal~$ ./get_observability_alert.sh hunter
{
"statusCode": 404,
"error": "Not Found",
"message": "Unauthorized to get \"rac:8.0.0:observability/get\" alert\""
}
```

View file

@ -0,0 +1,23 @@
#!/bin/sh
#
# 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.
#
set -e
USER=${1:-'observer'}
cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ..
# Example: ./find_rules.sh
curl -v -k \
-u $USER:changeme \
-X GET "${KIBANA_URL}${SPACE_URL}/internal/rac/alerts/index" | jq .
# -X GET "${KIBANA_URL}${SPACE_URL}/api/apm/settings/apm-alerts-as-data-indices" | jq .

View file

@ -0,0 +1,22 @@
#!/bin/sh
#
# 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.
#
set -e
USER=${1:-'observer'}
ID=${2:-'DHEnOXoB8br9Z2X1fq_l'}
cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ..
# Example: ./get_observability_alert.sh hunter
curl -v -k \
-u $USER:changeme \
-X GET "${KIBANA_URL}${SPACE_URL}/internal/rac/alerts?id=$ID&index=.alerts-observability-apm" | jq .

View file

@ -0,0 +1,22 @@
#!/bin/sh
#
# 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.
#
set -e
USER=${1:-'hunter'}
ID=${2:-'kdL4gHoBFALkyfScIsY5'}
cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ..
# Example: ./get_observability_alert.sh hunter
curl -v -k \
-u $USER:changeme \
-X GET "${KIBANA_URL}${SPACE_URL}/internal/rac/alerts?id=$ID&index=.alerts-security-solution" | jq .

View file

@ -0,0 +1,5 @@
This user can access the monitoring route at http://localhost:5601/security-myfakepath
| Role | Data Sources | Security Solution ML Jobs/Results | Lists | Rules/Exceptions | Action Connectors | Signals/Alerts |
| :-----------------: | :----------: | :-------------------------------: | :---: | :--------------: | :---------------: | :------------: |
| Hunter / T3 Analyst | read, write | read | read | read, write | read | read, write |

View file

@ -0,0 +1,11 @@
#
# 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.
#
curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-XDELETE ${ELASTICSEARCH_URL}/_security/user/hunter

View file

@ -0,0 +1,19 @@
{
"elasticsearch": {
"cluster": [],
"indices": []
},
"kibana": [
{
"feature": {
"ml": ["read"],
"siem": ["all"],
"actions": ["read"],
"ruleRegistry": ["all"],
"builtInAlerts": ["all"],
"alerting": ["all"]
},
"spaces": ["*"]
}
]
}

View file

@ -0,0 +1,6 @@
{
"password": "changeme",
"roles": ["hunter"],
"full_name": "Hunter",
"email": "detections-reader@example.com"
}

View file

@ -0,0 +1,11 @@
#
# 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.
#
curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-XGET ${KIBANA_URL}/api/security/role/hunter | jq -S .

View file

@ -0,0 +1,10 @@
/*
* 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 * as hunterUser from './detections_user.json';
import * as hunterRole from './detections_role.json';
export { hunterUser, hunterRole };

View file

@ -0,0 +1,14 @@
#
# 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.
#
ROLE=(${@:-./detections_role.json})
curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-XPUT ${KIBANA_URL}/api/security/role/hunter \
-d @${ROLE}

View file

@ -0,0 +1,14 @@
#
# 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.
#
USER=(${@:-./detections_user.json})
curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
${ELASTICSEARCH_URL}/_security/user/hunter \
-d @${USER}

View file

@ -0,0 +1,5 @@
This user can access the monitoring route at http://localhost:5601/monitoring-myfakepath
| Role | Data Sources | Security Solution ML Jobs/Results | Lists | Rules/Exceptions | Action Connectors | Signals/Alerts |
| :------: | :----------: | :-------------------------------: | :---: | :--------------: | :---------------: | :------------: |
| observer | read, write | read | read | read, write | read | read, write |

View file

@ -0,0 +1,11 @@
#
# 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.
#
curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-XDELETE ${ELASTICSEARCH_URL}/_security/user/observer

View file

@ -0,0 +1,20 @@
{
"elasticsearch": {
"cluster": [],
"indices": []
},
"kibana": [
{
"feature": {
"ml": ["read"],
"monitoring": ["all"],
"apm": ["minimal_read", "alerts_all"],
"ruleRegistry": ["all"],
"actions": ["read"],
"builtInAlerts": ["all"],
"alerting": ["all"]
},
"spaces": ["*"]
}
]
}

View file

@ -0,0 +1,6 @@
{
"password": "changeme",
"roles": ["observer"],
"full_name": "Observer",
"email": "monitoring-observer@example.com"
}

View file

@ -0,0 +1,11 @@
#
# 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.
#
curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-XGET ${KIBANA_URL}/api/security/role/hunter | jq -S .

View file

@ -0,0 +1,21 @@
#!/bin/sh
#
# 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.
#
set -e
USER=${1:-'observer'}
cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ..
# Example: ./find_rules.sh
curl -s -k \
-u $USER:changeme \
-X GET ${KIBANA_URL}${SPACE_URL}/monitoring-myfakepath | jq .

View file

@ -0,0 +1,22 @@
#!/bin/sh
#
# 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.
#
set -e
cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ..
USER=${1:-'hunter'}
# Example: ./find_rules.sh
curl -s -k \
-u $USER:changeme \
-X GET ${KIBANA_URL}${SPACE_URL}/security-myfakepath | jq .

View file

@ -0,0 +1,10 @@
/*
* 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 * as observerUser from './detections_user.json';
import * as observerRole from './detections_role.json';
export { observerUser, observerRole };

View file

@ -0,0 +1,14 @@
#
# 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.
#
ROLE=(${@:-./detections_role.json})
curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-XPUT ${KIBANA_URL}/api/security/role/observer \
-d @${ROLE}

View file

@ -0,0 +1,14 @@
#
# 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.
#
USER=(${@:-./detections_user.json})
curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
${ELASTICSEARCH_URL}/_security/user/observer \
-d @${USER}

View file

@ -0,0 +1,28 @@
#!/bin/sh
#
# 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.
#
set -e
IDS=${1}
STATUS=${2}
echo $IDS
echo "'"$STATUS"'"
cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ..
# Example: ./update_observability_alert.sh [\"my-alert-id\",\"another-alert-id\"] <closed | open>
curl -s -k \
-H 'Content-Type: application/json' \
-H 'kbn-xsrf: 123' \
-u observer:changeme \
-X POST ${KIBANA_URL}${SPACE_URL}/internal/rac/alerts \
-d "{\"ids\": $IDS, \"status\":\"$STATUS\", \"index\":\".alerts-observability-apm\"}" | jq .

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { RequestHandlerContext } from 'kibana/server';
import {
AlertInstanceContext,
AlertInstanceState,
@ -12,6 +13,7 @@ import {
AlertTypeState,
} from '../../alerting/common';
import { AlertType } from '../../alerting/server';
import { AlertsClient } from './alert_data_client/alerts_client';
type SimpleAlertType<
TParams extends AlertTypeParams = {},
@ -38,3 +40,17 @@ export type AlertTypeWithExecutor<
> & {
executor: AlertTypeExecutor<TParams, TAlertInstanceContext, TServices>;
};
/**
* @public
*/
export interface RacApiRequestHandlerContext {
getAlertsClient: () => Promise<AlertsClient>;
}
/**
* @internal
*/
export interface RacRequestHandlerContext extends RequestHandlerContext {
rac: RacApiRequestHandlerContext;
}

View file

@ -194,6 +194,7 @@ describe('createLifecycleRuleTypeFactory', () => {
"event.kind": "event",
"kibana.rac.alert.duration.us": 0,
"kibana.rac.alert.id": "opbeans-java",
"kibana.rac.alert.owner": "consumer",
"kibana.rac.alert.producer": "test",
"kibana.rac.alert.start": "2021-06-16T09:01:00.000Z",
"kibana.rac.alert.status": "open",
@ -212,6 +213,7 @@ describe('createLifecycleRuleTypeFactory', () => {
"event.kind": "event",
"kibana.rac.alert.duration.us": 0,
"kibana.rac.alert.id": "opbeans-node",
"kibana.rac.alert.owner": "consumer",
"kibana.rac.alert.producer": "test",
"kibana.rac.alert.start": "2021-06-16T09:01:00.000Z",
"kibana.rac.alert.status": "open",
@ -230,6 +232,7 @@ describe('createLifecycleRuleTypeFactory', () => {
"event.kind": "signal",
"kibana.rac.alert.duration.us": 0,
"kibana.rac.alert.id": "opbeans-java",
"kibana.rac.alert.owner": "consumer",
"kibana.rac.alert.producer": "test",
"kibana.rac.alert.start": "2021-06-16T09:01:00.000Z",
"kibana.rac.alert.status": "open",
@ -248,6 +251,7 @@ describe('createLifecycleRuleTypeFactory', () => {
"event.kind": "signal",
"kibana.rac.alert.duration.us": 0,
"kibana.rac.alert.id": "opbeans-node",
"kibana.rac.alert.owner": "consumer",
"kibana.rac.alert.producer": "test",
"kibana.rac.alert.start": "2021-06-16T09:01:00.000Z",
"kibana.rac.alert.status": "open",

View file

@ -25,6 +25,7 @@ import {
ALERT_UUID,
EVENT_ACTION,
EVENT_KIND,
OWNER,
RULE_UUID,
TIMESTAMP,
} from '../../common/technical_rule_data_field_names';
@ -69,6 +70,7 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({
const {
services: { alertInstanceFactory },
state: previousState,
rule,
} = options;
const ruleExecutorData = getRuleExecutorData(type, options);
@ -180,6 +182,7 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({
...ruleExecutorData,
[TIMESTAMP]: timestamp,
[EVENT_KIND]: 'event',
[OWNER]: rule.consumer,
[ALERT_ID]: alertId,
};
@ -234,6 +237,7 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({
[EVENT_KIND]: 'signal',
});
}
logger.debug(`Preparing to index ${eventsToIndex.length} alerts.`);
if (ruleDataClient.isWriteEnabled()) {
await ruleDataClient.getWriter().bulk({

View file

@ -0,0 +1,22 @@
/*
* 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.
*/
/**
* registering a new instance of the rule data client
* in a new plugin will require updating the below data structure
* to include the index name where the alerts as data will be written to.
*/
export const mapConsumerToIndexName = {
apm: '.alerts-observability-apm',
observability: '.alerts-observability',
siem: ['.alerts-security.alerts', '.siem-signals'],
};
export type ValidFeatureId = keyof typeof mapConsumerToIndexName;
export const validFeatureIds = Object.keys(mapConsumerToIndexName);
export const isValidFeatureId = (a: unknown): a is ValidFeatureId =>
typeof a === 'string' && validFeatureIds.includes(a);

View file

@ -7,11 +7,19 @@
"declaration": true,
"declarationMap": true
},
"include": ["common/**/*", "server/**/*", "public/**/*", "../../../typings/**/*"],
"include": [
"common/**/*",
"server/**/*",
// have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636
"server/**/*.json",
"public/**/*",
"../../../typings/**/*"
],
"references": [
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/data/tsconfig.json" },
{ "path": "../alerting/tsconfig.json" },
{ "path": "../security/tsconfig.json" },
{ "path": "../spaces/tsconfig.json" },
{ "path": "../triggers_actions_ui/tsconfig.json" }
]

View file

@ -238,6 +238,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
});
ruleDataClient = ruleDataService.getRuleDataClient(
SERVER_APP_ID,
ruleDataService.getFullAssetName('security.alerts'),
() => initializeRuleDataTemplatesPromise
);
@ -338,7 +339,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
all: {
app: [APP_ID, 'kibana'],
catalogue: ['securitySolution'],
api: ['securitySolution', 'lists-all', 'lists-read'],
api: ['securitySolution', 'lists-all', 'lists-read', 'rac'],
savedObject: {
all: ['alert', 'exception-list', 'exception-list-agnostic', ...savedObjectTypes],
read: [],
@ -359,7 +360,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
read: {
app: [APP_ID, 'kibana'],
catalogue: ['securitySolution'],
api: ['securitySolution', 'lists-read'],
api: ['securitySolution', 'lists-read', 'rac'],
savedObject: {
all: [],
read: ['exception-list', 'exception-list-agnostic', ...savedObjectTypes],

View file

@ -45,6 +45,9 @@ const onlyNotInCoverageTests = [
require.resolve('../test/detection_engine_api_integration/basic/config.ts'),
require.resolve('../test/lists_api_integration/security_and_spaces/config.ts'),
require.resolve('../test/plugin_api_integration/config.ts'),
require.resolve('../test/rule_registry/security_and_spaces/config_basic.ts'),
require.resolve('../test/rule_registry/security_and_spaces/config_trial.ts'),
require.resolve('../test/rule_registry/spaces_only/config_trial.ts'),
require.resolve('../test/security_api_integration/saml.config.ts'),
require.resolve('../test/security_api_integration/session_idle.config.ts'),
require.resolve('../test/security_api_integration/session_invalidate.config.ts'),

View file

@ -21,7 +21,23 @@ export default function ({ getService }: FtrProviderContext) {
// If you're removing a privilege, this breaks backwards compatibility
// Roles are associated with these privileges, and we shouldn't be removing them in a minor version.
const expected = {
global: ['all', 'read'],
space: ['all', 'read'],
features: {
graph: ['all', 'read'],
savedObjectsTagging: ['all', 'read'],
canvas: ['all', 'read', 'minimal_all', 'minimal_read', 'generate_report'],
maps: ['all', 'read'],
fleet: ['all', 'read'],
actions: ['all', 'read'],
stackAlerts: ['all', 'read'],
ml: ['all', 'read'],
siem: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_all', 'cases_read'],
observabilityCases: ['all', 'read'],
uptime: ['all', 'read'],
infrastructure: ['all', 'read'],
logs: ['all', 'read'],
apm: ['all', 'read', 'minimal_all', 'minimal_read', 'alerts_all', 'alerts_read'],
discover: [
'all',
'read',
@ -53,24 +69,8 @@ export default function ({ getService }: FtrProviderContext) {
advancedSettings: ['all', 'read'],
indexPatterns: ['all', 'read'],
savedObjectsManagement: ['all', 'read'],
savedObjectsTagging: ['all', 'read'],
timelion: ['all', 'read'],
graph: ['all', 'read'],
maps: ['all', 'read'],
canvas: ['all', 'read', 'minimal_all', 'minimal_read', 'generate_report'],
infrastructure: ['all', 'read'],
logs: ['all', 'read'],
observabilityCases: ['all', 'read'],
uptime: ['all', 'read'],
apm: ['all', 'read'],
ml: ['all', 'read'],
siem: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_all', 'cases_read'],
fleet: ['all', 'read'],
stackAlerts: ['all', 'read'],
actions: ['all', 'read'],
},
global: ['all', 'read'],
space: ['all', 'read'],
reserved: ['ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
};

View file

@ -7,6 +7,11 @@
import expect from '@kbn/expect';
import { secOnly } from '../../../rule_registry/common/lib/authentication/users';
import {
createSpacesAndUsers,
deleteSpacesAndUsers,
} from '../../../rule_registry/common/lib/authentication/';
import {
Direction,
TimelineEventsQueries,
@ -407,10 +412,19 @@ export default function ({ getService }: FtrProviderContext) {
const retry = getService('retry');
const esArchiver = getService('esArchiver');
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('Timeline', () => {
before(() => esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts'));
after(() => esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts'));
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts');
await createSpacesAndUsers(getService);
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts');
await deleteSpacesAndUsers(getService);
});
it('Make sure that we get Timeline data', async () => {
await retry.try(async () => {
@ -454,6 +468,60 @@ export default function ({ getService }: FtrProviderContext) {
});
});
// TODO: unskip this test once authz is added to search strategy
it.skip('Make sure that we get Timeline data using the hunter role and do not receive observability alerts', async () => {
await retry.try(async () => {
const requestBody = {
defaultIndex: ['.alerts*'], // query both .alerts-observability-apm and .alerts-security-solution
docValueFields: [],
factoryQueryType: TimelineEventsQueries.all,
fieldRequested: FIELD_REQUESTED,
// fields: [],
filterQuery: {
bool: {
filter: [
{
match_all: {},
},
],
},
},
pagination: {
activePage: 0,
querySize: 25,
},
language: 'kuery',
sort: [
{
field: '@timestamp',
direction: Direction.desc,
type: 'number',
},
],
timerange: {
from: FROM,
to: TO,
interval: '12h',
},
};
const resp = await supertestWithoutAuth
.post('/internal/search/securitySolutionTimelineSearchStrategy/')
.auth(secOnly.username, secOnly.password) // using security 'hunter' role
.set('kbn-xsrf', 'true')
.set('Content-Type', 'application/json')
.send(requestBody)
.expect(200);
const timeline = resp.body;
// we inject one alert into the security solutions alerts index and another alert into the observability alerts index
// therefore when accessing the .alerts* index with the security solution user,
// only security solution alerts should be returned since the security solution user
// is not authorized to view observability alerts.
expect(timeline.totalCount).to.be(1);
});
});
it('Make sure that pagination is working in Timeline query', async () => {
await retry.try(async () => {
const resp = await supertest

View file

@ -367,6 +367,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
"kibana.rac.alert.id": Array [
"apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED",
],
"kibana.rac.alert.owner": Array [
"apm",
],
"kibana.rac.alert.producer": Array [
"apm",
],
@ -437,6 +440,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
"kibana.rac.alert.id": Array [
"apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED",
],
"kibana.rac.alert.owner": Array [
"apm",
],
"kibana.rac.alert.producer": Array [
"apm",
],
@ -541,6 +547,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
"kibana.rac.alert.id": Array [
"apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED",
],
"kibana.rac.alert.owner": Array [
"apm",
],
"kibana.rac.alert.producer": Array [
"apm",
],

View file

@ -0,0 +1,29 @@
{
"type": "doc",
"value": {
"index": ".alerts-observability-apm",
"id": "NoxgpHkBqbdrfX07MqXV",
"source": {
"@timestamp": "2020-12-16T15:16:18.570Z",
"rule.id": "apm.error_rate",
"message": "hello world 1",
"kibana.rac.alert.owner": "apm",
"kibana.rac.alert.status": "open"
}
}
}
{
"type": "doc",
"value": {
"index": ".alerts-security.alerts",
"id": "020202",
"source": {
"@timestamp": "2020-12-16T15:16:18.570Z",
"rule.id": "siem.signals",
"message": "hello world security",
"kibana.rac.alert.owner": "siem",
"kibana.rac.alert.status": "open"
}
}
}

View file

@ -0,0 +1,47 @@
{
"type": "index",
"value": {
"index": ".alerts-observability-apm",
"mappings": {
"properties": {
"message": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"kibana.rac.alert.owner": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
{
"type": "index",
"value": {
"index": ".alerts-security.alerts",
"mappings": {
"properties": {
"message": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"kibana.rac.alert.owner": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CA_CERT_PATH } from '@kbn/dev-utils';
import { FtrConfigProviderContext } from '@kbn/test';
import { services } from './services';
import { getAllExternalServiceSimulatorPaths } from '../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin';
interface CreateTestConfigOptions {
license: string;
disabledPlugins?: string[];
ssl?: boolean;
testFiles?: string[];
}
// test.not-enabled is specifically not enabled
const enabledActionTypes = [
'.email',
'.index',
'.jira',
'.pagerduty',
'.resilient',
'.server-log',
'.servicenow',
'.servicenow-sir',
'.slack',
'.webhook',
'.case',
'test.authorization',
'test.failing',
'test.index-record',
'test.noop',
'test.rate-limit',
];
export function createTestConfig(name: string, options: CreateTestConfigOptions) {
const { license = 'trial', disabledPlugins = [], ssl = false, testFiles = [] } = options;
return async ({ readConfigFile }: FtrConfigProviderContext) => {
const xPackApiIntegrationTestsConfig = await readConfigFile(
require.resolve('../../api_integration/config.ts')
);
const servers = {
...xPackApiIntegrationTestsConfig.get('servers'),
elasticsearch: {
...xPackApiIntegrationTestsConfig.get('servers.elasticsearch'),
protocol: ssl ? 'https' : 'http',
},
};
return {
testFiles: testFiles ? testFiles : [require.resolve('../tests/common')],
servers,
services,
junit: {
reportName: 'X-Pack Rule Registry Alerts Client API Integration Tests',
},
esTestCluster: {
...xPackApiIntegrationTestsConfig.get('esTestCluster'),
license,
ssl,
serverArgs: [
`xpack.license.self_generated.type=${license}`,
`xpack.security.enabled=${
!disabledPlugins.includes('security') && ['trial', 'basic'].includes(license)
}`,
],
},
kbnTestServer: {
...xPackApiIntegrationTestsConfig.get('kbnTestServer'),
serverArgs: [
...xPackApiIntegrationTestsConfig.get('kbnTestServer.serverArgs'),
`--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`,
`--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`,
'--xpack.eventLog.logEntries=true',
...disabledPlugins.map((key) => `--xpack.${key}.enabled=false`),
`--server.xsrf.whitelist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`,
...(ssl
? [
`--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`,
`--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`,
]
: []),
],
},
};
};
}

View file

@ -0,0 +1,12 @@
/*
* 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 { GenericFtrProviderContext } from '@kbn/test';
import { services } from './services';
export type FtrProviderContext = GenericFtrProviderContext<typeof services, {}>;

View file

@ -0,0 +1,105 @@
/*
* 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 { FtrProviderContext as CommonFtrProviderContext } from '../../../common/ftr_provider_context';
import { Role, User, UserInfo } from './types';
import { allUsers } from './users';
import { allRoles } from './roles';
import { spaces } from './spaces';
export const getUserInfo = (user: User): UserInfo => ({
username: user.username,
full_name: user.username.replace('_', ' '),
email: `${user.username}@elastic.co`,
});
export const createSpaces = async (getService: CommonFtrProviderContext['getService']) => {
const spacesService = getService('spaces');
for (const space of spaces) {
await spacesService.create(space);
}
};
/**
* Creates the users and roles for use in the tests. Defaults to specific users and roles used by the security_and_spaces
* scenarios but can be passed specific ones as well.
*/
export const createUsersAndRoles = async (
getService: CommonFtrProviderContext['getService'],
usersToCreate: User[] = allUsers,
rolesToCreate: Role[] = allRoles
) => {
const security = getService('security');
const createRole = async ({ name, privileges }: Role) => {
return security.role.create(name, privileges);
};
const createUser = async (user: User) => {
const userInfo = getUserInfo(user);
return security.user.create(user.username, {
password: user.password,
roles: user.roles,
full_name: userInfo.full_name,
email: userInfo.email,
});
};
for (const role of rolesToCreate) {
await createRole(role);
}
for (const user of usersToCreate) {
await createUser(user);
}
};
export const deleteSpaces = async (getService: CommonFtrProviderContext['getService']) => {
const spacesService = getService('spaces');
for (const space of spaces) {
try {
await spacesService.delete(space.id);
} catch (error) {
// ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users
}
}
};
export const deleteUsersAndRoles = async (
getService: CommonFtrProviderContext['getService'],
usersToDelete: User[] = allUsers,
rolesToDelete: Role[] = allRoles
) => {
const security = getService('security');
for (const user of usersToDelete) {
try {
await security.user.delete(user.username);
} catch (error) {
// ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users
}
}
for (const role of rolesToDelete) {
try {
await security.role.delete(role.name);
} catch (error) {
// ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users
}
}
};
export const createSpacesAndUsers = async (getService: CommonFtrProviderContext['getService']) => {
await createSpaces(getService);
await createUsersAndRoles(getService);
};
export const deleteSpacesAndUsers = async (getService: CommonFtrProviderContext['getService']) => {
await deleteSpaces(getService);
await deleteUsersAndRoles(getService);
};

Some files were not shown because too many files have changed in this diff Show more