[RAC] [RBAC] Adds bulk update route to rule registry and bulk update function to alerts client (#106297)

Adds a bulk update route (POST /internal/rac/alerts/bulk_update) to the rule registry and bulkUpdate function to the alerts as data client.
This commit is contained in:
Devin W. Hurley 2021-08-09 15:39:48 -04:00 committed by GitHub
parent bc171418d2
commit ab43afab88
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1903 additions and 321 deletions

View file

@ -22,8 +22,10 @@ NPM_MODULE_EXTRA_FILES = [
]
SRC_DEPS = [
"//packages/kbn-es-query",
"@npm//tslib",
"@npm//utility-types",
"@npm//@elastic/elasticsearch",
]
TYPES_DEPS = [

View file

@ -6,6 +6,9 @@
* Side Public License, v 1.
*/
import type { estypes } from '@elastic/elasticsearch';
import type { EsQueryConfig } from '@kbn/es-query';
/**
* registering a new instance of the rule data client
* in a new plugin will require updating the below data structure
@ -24,6 +27,7 @@ export const AlertConsumers = {
SYNTHETICS: 'synthetics',
} as const;
export type AlertConsumers = typeof AlertConsumers[keyof typeof AlertConsumers];
export type STATUS_VALUES = 'open' | 'acknowledged' | 'closed';
export const mapConsumerToIndexName: Record<AlertConsumers, string | string[]> = {
apm: '.alerts-observability-apm',
@ -38,3 +42,57 @@ 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);
/**
* Prevent javascript from returning Number.MAX_SAFE_INTEGER when Elasticsearch expects
* Java's Long.MAX_VALUE. This happens when sorting fields by date which are
* unmapped in the provided index
*
* Ref: https://github.com/elastic/elasticsearch/issues/28806#issuecomment-369303620
*
* return stringified Long.MAX_VALUE if we receive Number.MAX_SAFE_INTEGER
* @param sortIds estypes.SearchSortResults | undefined
* @returns SortResults
*/
export const getSafeSortIds = (sortIds: estypes.SearchSortResults | null | undefined) => {
if (sortIds == null) {
return sortIds;
}
return sortIds.map((sortId) => {
// haven't determined when we would receive a null value for a sort id
// but in case we do, default to sending the stringified Java max_int
if (sortId == null || sortId === '' || sortId >= Number.MAX_SAFE_INTEGER) {
return '9223372036854775807';
}
return sortId;
});
};
interface GetEsQueryConfigParamType {
allowLeadingWildcards?: EsQueryConfig['allowLeadingWildcards'];
queryStringOptions?: EsQueryConfig['queryStringOptions'];
ignoreFilterIfFieldNotInIndex?: EsQueryConfig['ignoreFilterIfFieldNotInIndex'];
dateFormatTZ?: EsQueryConfig['dateFormatTZ'];
}
type ConfigKeys = keyof GetEsQueryConfigParamType;
export const getEsQueryConfig = (params?: GetEsQueryConfigParamType): EsQueryConfig => {
const defaultConfigValues = {
allowLeadingWildcards: true,
queryStringOptions: { analyze_wildcard: true },
ignoreFilterIfFieldNotInIndex: false,
dateFormatTZ: 'Zulu',
};
if (params == null) {
return defaultConfigValues;
}
const paramKeysWithValues = Object.keys(params).reduce((acc: EsQueryConfig, key) => {
const configKey = key as ConfigKeys;
if (params[configKey] != null) {
return { [key]: params[configKey], ...acc };
}
return { [key]: defaultConfigValues[configKey], ...acc };
}, {} as EsQueryConfig);
return paramKeysWithValues;
};

View file

@ -15,6 +15,7 @@ const createAlertingAuthorizationMock = () => {
const mocked: AlertingAuthorizationMock = {
ensureAuthorized: jest.fn(),
filterByRuleTypeAuthorization: jest.fn(),
getAuthorizationFilter: jest.fn(),
getFindAuthorizationFilter: jest.fn(),
getAugmentedRuleTypesWithAuthorization: jest.fn(),
getSpaceId: jest.fn(),

View file

@ -281,11 +281,23 @@ export class AlertingAuthorization {
filter?: KueryNode | JsonObject;
ensureRuleTypeIsAuthorized: (ruleTypeId: string, consumer: string, auth: string) => void;
logSuccessfulAuthorization: () => void;
}> {
return this.getAuthorizationFilter(authorizationEntity, filterOpts, ReadOperations.Find);
}
public async getAuthorizationFilter(
authorizationEntity: AlertingAuthorizationEntity,
filterOpts: AlertingAuthorizationFilterOpts,
operation: WriteOperations | ReadOperations
): Promise<{
filter?: KueryNode | JsonObject;
ensureRuleTypeIsAuthorized: (ruleTypeId: string, consumer: string, auth: string) => void;
logSuccessfulAuthorization: () => void;
}> {
if (this.authorization && this.shouldCheckAuthorization()) {
const { username, authorizedRuleTypes } = await this.augmentRuleTypesWithAuthorization(
this.ruleTypeRegistry.list(),
[ReadOperations.Find],
[operation],
authorizationEntity
);

View file

@ -41,6 +41,7 @@ const alert: Alert = {
'rule.name': ['Latency threshold | frontend-rum'],
[ALERT_DURATION]: [62879000],
[ALERT_STATUS]: ['open'],
[SPACE_IDS]: ['myfakespaceid'],
tags: ['apm', 'service.name:frontend-rum'],
'transaction.type': ['page-load'],
[ALERT_PRODUCER]: ['apm'],

View file

@ -10,5 +10,6 @@ Alerts as data client API Interface
### Interfaces
- [BulkUpdateOptions](interfaces/bulkupdateoptions.md)
- [ConstructorOptions](interfaces/constructoroptions.md)
- [UpdateOptions](interfaces/updateoptions.md)

View file

@ -22,10 +22,14 @@ on alerts as data.
### Methods
- [fetchAlert](alertsclient.md#fetchalert)
- [buildEsQueryWithAuthz](alertsclient.md#buildesquerywithauthz)
- [bulkUpdate](alertsclient.md#bulkupdate)
- [ensureAllAuthorized](alertsclient.md#ensureallauthorized)
- [get](alertsclient.md#get)
- [getAlertsIndex](alertsclient.md#getalertsindex)
- [getAuthorizedAlertsIndices](alertsclient.md#getauthorizedalertsindices)
- [mgetAlertsAuditOperate](alertsclient.md#mgetalertsauditoperate)
- [queryAndAuditAllAlerts](alertsclient.md#queryandauditallalerts)
- [singleSearchAfterAndAudit](alertsclient.md#singlesearchafterandaudit)
- [update](alertsclient.md#update)
## Constructors
@ -42,7 +46,7 @@ on alerts as data.
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:66](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L66)
[alerts_client.ts:93](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L93)
## Properties
@ -52,7 +56,7 @@ on alerts as data.
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:63](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L63)
[alerts_client.ts:90](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L90)
___
@ -62,7 +66,7 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:64](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L64)
[alerts_client.ts:91](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L91)
___
@ -72,7 +76,7 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:65](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L65)
[alerts_client.ts:92](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L92)
___
@ -82,70 +86,33 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:62](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L62)
[alerts_client.ts:89](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L89)
___
### spaceId
`Private` `Readonly` **spaceId**: `Promise`<undefined \| string\>
`Private` `Readonly` **spaceId**: `undefined` \| `string`
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:66](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L66)
[alerts_client.ts:93](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L93)
## Methods
### fetchAlert
### buildEsQueryWithAuthz
`Private` **fetchAlert**(`__namedParameters`): `Promise`<undefined \| ``null`` \| `Omit`<OutputOf<SetOptional<`Object`\>\>, ``"kibana.alert.owner"`` \| ``"rule.id"``\> & { `kibana.alert.owner`: `string` ; `rule.id`: `string` } & { `_version`: `undefined` \| `string` }\>
`Private` **buildEsQueryWithAuthz**(`query`, `id`, `alertSpaceId`, `operation`, `config`): `Promise`<`Object`\>
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | `GetAlertParams` |
#### Returns
`Promise`<undefined \| ``null`` \| `Omit`<OutputOf<SetOptional<`Object`\>\>, ``"kibana.alert.owner"`` \| ``"rule.id"``\> & { `kibana.alert.owner`: `string` ; `rule.id`: `string` } & { `_version`: `undefined` \| `string` }\>
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:87](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L87)
___
### get
**get**(`__namedParameters`): `Promise`<undefined \| ``null`` \| OutputOf<SetOptional<`Object`\>\>\>
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | `GetAlertParams` |
#### Returns
`Promise`<undefined \| ``null`` \| OutputOf<SetOptional<`Object`\>\>\>
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:134](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L134)
___
### getAlertsIndex
**getAlertsIndex**(`featureIds`, `operations`): `Promise`<`Object`\>
#### Parameters
| Name | Type |
| :------ | :------ |
| `featureIds` | `string`[] |
| `operations` | (`ReadOperations` \| `WriteOperations`)[] |
| `query` | `undefined` \| ``null`` \| `string` |
| `id` | `undefined` \| ``null`` \| `string` |
| `alertSpaceId` | `string` |
| `operation` | `Get` \| `Find` \| `Update` |
| `config` | `EsQueryConfig` |
#### Returns
@ -153,7 +120,76 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:76](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L76)
[alerts_client.ts:305](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L305)
___
### bulkUpdate
**bulkUpdate**<Params\>(`__namedParameters`): `Promise`<ApiResponse<BulkResponse, unknown\> \| ApiResponse<UpdateByQueryResponse, unknown\>\>
#### Type parameters
| Name | Type |
| :------ | :------ |
| `Params` | `Params`: `AlertTypeParams` = `never` |
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | [BulkUpdateOptions](../interfaces/bulkupdateoptions.md)<Params\> |
#### Returns
`Promise`<ApiResponse<BulkResponse, unknown\> \| ApiResponse<UpdateByQueryResponse, unknown\>\>
#### Defined in
[alerts_client.ts:475](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L475)
___
### ensureAllAuthorized
`Private` **ensureAllAuthorized**(`items`, `operation`): `Promise`<(undefined \| void)[]\>
Accepts an array of ES documents and executes ensureAuthorized for the given operation
#### Parameters
| Name | Type |
| :------ | :------ |
| `items` | { `_id`: `string` ; `_source?`: ``null`` \| { `kibana.alert.owner?`: ``null`` \| `string` ; `rule.id?`: ``null`` \| `string` } }[] |
| `operation` | `Get` \| `Find` \| `Update` |
#### Returns
`Promise`<(undefined \| void)[]\>
#### Defined in
[alerts_client.ts:111](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L111)
___
### get
**get**(`__namedParameters`): `Promise`<undefined \| OutputOf<SetOptional<`Object`\>\>\>
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | `GetAlertParams` |
#### Returns
`Promise`<undefined \| OutputOf<SetOptional<`Object`\>\>\>
#### Defined in
[alerts_client.ts:407](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L407)
___
@ -173,13 +209,87 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:238](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L238)
[alerts_client.ts:533](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L533)
___
### mgetAlertsAuditOperate
`Private` **mgetAlertsAuditOperate**(`__namedParameters`): `Promise`<ApiResponse<BulkResponse, unknown\>\>
When an update by ids is requested, do a multi-get, ensure authz and audit alerts, then execute bulk update
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | `Object` |
| `__namedParameters.ids` | `string`[] |
| `__namedParameters.indexName` | `string` |
| `__namedParameters.operation` | `Get` \| `Find` \| `Update` |
| `__namedParameters.status` | `STATUS\_VALUES` |
#### Returns
`Promise`<ApiResponse<BulkResponse, unknown\>\>
#### Defined in
[alerts_client.ts:252](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L252)
___
### queryAndAuditAllAlerts
`Private` **queryAndAuditAllAlerts**(`__namedParameters`): `Promise`<undefined \| { `auditedAlerts`: `boolean` = true; `authorizedQuery`: {} }\>
executes a search after to find alerts with query (+ authz filter)
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | `Object` |
| `__namedParameters.index` | `string` |
| `__namedParameters.operation` | `Get` \| `Find` \| `Update` |
| `__namedParameters.query` | `string` |
#### Returns
`Promise`<undefined \| { `auditedAlerts`: `boolean` = true; `authorizedQuery`: {} }\>
#### Defined in
[alerts_client.ts:343](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L343)
___
### singleSearchAfterAndAudit
`Private` **singleSearchAfterAndAudit**(`__namedParameters`): `Promise`<SearchResponse<OutputOf<SetOptional<`Object`\>\>\>\>
This will be used as a part of the "find" api
In the future we will add an "aggs" param
#### Parameters
| Name | Type |
| :------ | :------ |
| `__namedParameters` | `SingleSearchAfterAndAudit` |
#### Returns
`Promise`<SearchResponse<OutputOf<SetOptional<`Object`\>\>\>\>
#### Defined in
[alerts_client.ts:176](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L176)
___
### update
**update**<Params\>(`__namedParameters`): `Promise`<undefined \| { `_version`: `undefined` \| `string` }\>
**update**<Params\>(`__namedParameters`): `Promise`<`Object`\>
#### Type parameters
@ -195,8 +305,8 @@ ___
#### Returns
`Promise`<undefined \| { `_version`: `undefined` \| `string` }\>
`Promise`<`Object`\>
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:179](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L179)
[alerts_client.ts:432](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L432)

View file

@ -0,0 +1,58 @@
[Alerts as data client API Interface](../alerts_client_api.md) / BulkUpdateOptions
# Interface: BulkUpdateOptions<Params\>
## Type parameters
| Name | Type |
| :------ | :------ |
| `Params` | `Params`: `AlertTypeParams` |
## Table of contents
### Properties
- [ids](bulkupdateoptions.md#ids)
- [index](bulkupdateoptions.md#index)
- [query](bulkupdateoptions.md#query)
- [status](bulkupdateoptions.md#status)
## Properties
### ids
**ids**: `undefined` \| ``null`` \| `string`[]
#### Defined in
[alerts_client.ts:64](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L64)
___
### index
**index**: `string`
#### Defined in
[alerts_client.ts:66](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L66)
___
### query
**query**: `undefined` \| ``null`` \| `string`
#### Defined in
[alerts_client.ts:67](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L67)
___
### status
**status**: `STATUS\_VALUES`
#### Defined in
[alerts_client.ts:65](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L65)

View file

@ -19,7 +19,7 @@
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:40](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L40)
[alerts_client.ts:52](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L52)
___
@ -29,7 +29,7 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:39](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L39)
[alerts_client.ts:51](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L51)
___
@ -39,7 +39,7 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:41](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L41)
[alerts_client.ts:53](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L53)
___
@ -49,4 +49,4 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:38](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L38)
[alerts_client.ts:50](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L50)

View file

@ -25,7 +25,7 @@
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:47](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L47)
[alerts_client.ts:59](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L59)
___
@ -35,7 +35,7 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:45](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L45)
[alerts_client.ts:57](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L57)
___
@ -45,7 +45,7 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:48](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L48)
[alerts_client.ts:60](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L60)
___
@ -55,4 +55,4 @@ ___
#### Defined in
[rule_registry/server/alert_data_client/alerts_client.ts:46](https://github.com/elastic/kibana/blob/48e1b91d751/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L46)
[alerts_client.ts:58](https://github.com/elastic/kibana/blob/daf6871ba4b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L58)

View file

@ -14,9 +14,9 @@ export type AlertsClientMock = jest.Mocked<Schema>;
const createAlertsClientMock = () => {
const mocked: AlertsClientMock = {
get: jest.fn(),
getAlertsIndex: jest.fn(),
update: jest.fn(),
getAuthorizedAlertsIndices: jest.fn(),
bulkUpdate: jest.fn(),
};
return mocked;
};

View file

@ -4,23 +4,28 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import Boom from '@hapi/boom';
import { PublicMethodsOf } from '@kbn/utility-types';
import { Filter, buildEsQuery, EsQueryConfig } from '@kbn/es-query';
import { decodeVersion, encodeHitVersion } from '@kbn/securitysolution-es-utils';
import {
mapConsumerToIndexName,
validFeatureIds,
isValidFeatureId,
getSafeSortIds,
STATUS_VALUES,
getEsQueryConfig,
} from '@kbn/rule-data-utils/target/alerts_as_data_rbac';
import { AlertTypeParams } from '../../../alerting/server';
import { InlineScript, QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import { AlertTypeParams, AlertingAuthorizationFilterType } 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 { Logger, ElasticsearchClient, EcsEventOutcome } from '../../../../../src/core/server';
import { alertAuditEvent, operationAlertAuditActionMap } from './audit_events';
import { AuditLogger } from '../../../security/server';
import {
ALERT_STATUS,
@ -33,10 +38,13 @@ import { ParsedTechnicalFields } from '../../common/parse_technical_fields';
// 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.alert.owner'>;
type AlertType = NonNullableProps<
ParsedTechnicalFields,
typeof RULE_ID | typeof ALERT_OWNER | typeof SPACE_IDS
>;
const isValidAlert = (source?: ParsedTechnicalFields): source is AlertType => {
return source?.[RULE_ID] != null && source?.[ALERT_OWNER] != null;
return source?.[RULE_ID] != null && source?.[ALERT_OWNER] != null && source?.[SPACE_IDS] != null;
};
export interface ConstructorOptions {
logger: Logger;
@ -52,11 +60,26 @@ export interface UpdateOptions<Params extends AlertTypeParams> {
index: string;
}
export interface BulkUpdateOptions<Params extends AlertTypeParams> {
ids: string[] | undefined | null;
status: STATUS_VALUES;
index: string;
query: string | undefined | null;
}
interface GetAlertParams {
id: string;
index?: string;
}
interface SingleSearchAfterAndAudit {
id: string | null | undefined;
query: string | null | undefined;
index?: string;
operation: WriteOperations.Update | ReadOperations.Find | ReadOperations.Get;
lastSortIds: Array<string | number> | undefined;
}
/**
* Provides apis to interact with alerts as data
* ensures the request is authorized to perform read / write actions
@ -79,105 +102,337 @@ export class AlertsClient {
this.spaceId = this.authorization.getSpaceId();
}
public async getAlertsIndex(
featureIds: string[],
operations: Array<ReadOperations | WriteOperations>
) {
return this.authorization.getAugmentedRuleTypesWithAuthorization(
featureIds.length !== 0 ? featureIds : validFeatureIds,
operations,
AlertingAuthorizationEntity.Alert
);
private getOutcome(
operation: WriteOperations.Update | ReadOperations.Find | ReadOperations.Get
): { outcome: EcsEventOutcome } {
return {
outcome: operation === WriteOperations.Update ? 'unknown' : 'success',
};
}
private async fetchAlert({
/**
* Accepts an array of ES documents and executes ensureAuthorized for the given operation
* @param items
* @param operation
* @returns
*/
private async ensureAllAuthorized(
items: Array<{
_id: string;
// this is typed kind of crazy to fit the output of es api response to this
_source?:
| { [RULE_ID]?: string | null | undefined; [ALERT_OWNER]?: string | null | undefined }
| null
| undefined;
}>,
operation: ReadOperations.Find | ReadOperations.Get | WriteOperations.Update
) {
const { hitIds, ownersAndRuleTypeIds } = items.reduce(
(acc, hit) => ({
hitIds: [hit._id, ...acc.hitIds],
ownersAndRuleTypeIds: [
{
[RULE_ID]: hit?._source?.[RULE_ID],
[ALERT_OWNER]: hit?._source?.[ALERT_OWNER],
},
],
}),
{ hitIds: [], ownersAndRuleTypeIds: [] } as {
hitIds: string[];
ownersAndRuleTypeIds: Array<{
[RULE_ID]: string | null | undefined;
[ALERT_OWNER]: string | null | undefined;
}>;
}
);
const assertString = (hit: unknown): hit is string => hit !== null && hit !== undefined;
return Promise.all(
ownersAndRuleTypeIds.map((hit) => {
const alertOwner = hit?.[ALERT_OWNER];
const ruleId = hit?.[RULE_ID];
if (hit != null && assertString(alertOwner) && assertString(ruleId)) {
return this.authorization.ensureAuthorized({
ruleTypeId: ruleId,
consumer: alertOwner,
operation,
entity: AlertingAuthorizationEntity.Alert,
});
}
})
).catch((error) => {
for (const hitId of hitIds) {
this.auditLogger?.log(
alertAuditEvent({
action: operationAlertAuditActionMap[operation],
id: hitId,
error,
})
);
}
throw error;
});
}
/**
* This will be used as a part of the "find" api
* In the future we will add an "aggs" param
* @param param0
* @returns
*/
private async singleSearchAfterAndAudit({
id,
query,
index,
}: GetAlertParams): Promise<(AlertType & { _version: string | undefined }) | null | undefined> {
operation,
lastSortIds = [],
}: SingleSearchAfterAndAudit) {
try {
const alertSpaceId = this.spaceId;
if (alertSpaceId == null) {
this.logger.error('Failed to acquire spaceId from authorization client');
return;
const errorMessage = 'Failed to acquire spaceId from authorization client';
this.logger.error(`fetchAlertAndAudit threw an error: ${errorMessage}`);
throw Boom.failedDependency(`fetchAlertAndAudit threw an error: ${errorMessage}`);
}
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: {
bool: {
filter: [{ term: { _id: id } }, { term: { [SPACE_IDS]: alertSpaceId } }],
const config = getEsQueryConfig();
let queryBody = {
query: await this.buildEsQueryWithAuthz(query, id, alertSpaceId, operation, config),
sort: [
{
'@timestamp': {
order: 'asc',
unmapped_type: 'date',
},
},
},
],
};
if (lastSortIds.length > 0) {
queryBody = {
...queryBody,
// @ts-expect-error
search_after: lastSortIds,
};
}
const result = await this.esClient.search<ParsedTechnicalFields>({
index: index ?? '.alerts-*',
ignore_unavailable: true,
// @ts-expect-error
body: queryBody,
seq_no_primary_term: true,
});
if (result == null || result.body == null || result.body.hits.hits.length === 0) {
return;
if (!result?.body.hits.hits.every((hit) => isValidAlert(hit._source))) {
const errorMessage = `Invalid alert found with id of "${id}" or with query "${query}" and operation ${operation}`;
this.logger.error(errorMessage);
throw Boom.badData(errorMessage);
}
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);
}
await this.ensureAllAuthorized(result.body.hits.hits, operation);
return {
...result.body.hits.hits[0]._source,
_version: encodeHitVersion(result.body.hits.hits[0]),
};
result?.body.hits.hits.map((item) =>
this.auditLogger?.log(
alertAuditEvent({
action: operationAlertAuditActionMap[operation],
id: item._id,
...this.getOutcome(operation),
})
)
);
return result.body;
} catch (error) {
const errorMessage = `Unable to retrieve alert with id of "${id}".`;
this.logger.debug(errorMessage);
throw error;
const errorMessage = `Unable to retrieve alert details for alert with id of "${id}" or with query "${query}" and operation ${operation} \nError: ${error}`;
this.logger.error(errorMessage);
throw Boom.notFound(errorMessage);
}
}
public async get({
id,
index,
}: GetAlertParams): Promise<ParsedTechnicalFields | null | undefined> {
/**
* When an update by ids is requested, do a multi-get, ensure authz and audit alerts, then execute bulk update
* @param param0
* @returns
*/
private async mgetAlertsAuditOperate({
ids,
status,
indexName,
operation,
}: {
ids: string[];
status: STATUS_VALUES;
indexName: string;
operation: ReadOperations.Find | ReadOperations.Get | WriteOperations.Update;
}) {
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,
const mgetRes = await this.esClient.mget<ParsedTechnicalFields>({
index: indexName,
body: {
ids,
},
});
if (alert == null) {
return;
await this.ensureAllAuthorized(mgetRes.body.docs, operation);
for (const id of ids) {
this.auditLogger?.log(
alertAuditEvent({
action: operationAlertAuditActionMap[operation],
id,
...this.getOutcome(operation),
})
);
}
// this.authorization leverages the alerting plugin's authorization
// client exposed to us for reuse
await this.authorization.ensureAuthorized({
ruleTypeId: alert[RULE_ID],
consumer: alert[ALERT_OWNER],
const bulkUpdateRequest = mgetRes.body.docs.flatMap((item) => [
{
update: {
_index: item._index,
_id: item._id,
},
},
{
doc: { [ALERT_STATUS]: status },
},
]);
const bulkUpdateResponse = await this.esClient.bulk({
body: bulkUpdateRequest,
});
return bulkUpdateResponse;
} catch (exc) {
this.logger.error(`error in mgetAlertsAuditOperate ${exc}`);
throw exc;
}
}
private async buildEsQueryWithAuthz(
query: string | null | undefined,
id: string | null | undefined,
alertSpaceId: string,
operation: WriteOperations.Update | ReadOperations.Get | ReadOperations.Find,
config: EsQueryConfig
) {
try {
const { filter: authzFilter } = await this.authorization.getAuthorizationFilter(
AlertingAuthorizationEntity.Alert,
{
type: AlertingAuthorizationFilterType.ESDSL,
fieldNames: { consumer: ALERT_OWNER, ruleTypeId: RULE_ID },
},
operation
);
return buildEsQuery(
undefined,
{ query: query == null ? `_id:${id}` : query, language: 'kuery' },
[
(authzFilter as unknown) as Filter,
({ term: { [SPACE_IDS]: alertSpaceId } } as unknown) as Filter,
],
config
);
} catch (exc) {
this.logger.error(exc);
throw Boom.expectationFailed(
`buildEsQueryWithAuthz threw an error: unable to get authorization filter \n ${exc}`
);
}
}
/**
* executes a search after to find alerts with query (+ authz filter)
* @param param0
* @returns
*/
private async queryAndAuditAllAlerts({
index,
query,
operation,
}: {
index: string;
query: string;
operation: WriteOperations.Update | ReadOperations.Find | ReadOperations.Get;
}) {
let lastSortIds;
let hasSortIds = true;
const alertSpaceId = this.spaceId;
if (alertSpaceId == null) {
this.logger.error('Failed to acquire spaceId from authorization client');
return;
}
const config = getEsQueryConfig();
const authorizedQuery = await this.buildEsQueryWithAuthz(
query,
null,
alertSpaceId,
operation,
config
);
while (hasSortIds) {
try {
const result = await this.singleSearchAfterAndAudit({
id: null,
query,
index,
operation,
lastSortIds,
});
if (lastSortIds != null && result?.hits.hits.length === 0) {
return { auditedAlerts: true, authorizedQuery };
}
if (result == null) {
this.logger.error('RESULT WAS EMPTY');
return { auditedAlerts: false, authorizedQuery };
}
if (result.hits.hits.length === 0) {
this.logger.error('Search resulted in no hits');
return { auditedAlerts: true, authorizedQuery };
}
lastSortIds = getSafeSortIds(result.hits.hits[result.hits.hits.length - 1]?.sort);
if (lastSortIds != null && lastSortIds.length !== 0) {
hasSortIds = true;
} else {
hasSortIds = false;
return { auditedAlerts: true, authorizedQuery };
}
} catch (error) {
const errorMessage = `queryAndAuditAllAlerts threw an error: Unable to retrieve alerts with query "${query}" and operation ${operation} \n ${error}`;
this.logger.error(errorMessage);
throw Boom.notFound(errorMessage);
}
}
}
public async get({ id, index }: GetAlertParams) {
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.singleSearchAfterAndAudit({
id,
query: null,
index,
operation: ReadOperations.Get,
entity: AlertingAuthorizationEntity.Alert,
lastSortIds: undefined,
});
this.auditLogger?.log(
alertAuditEvent({
action: AlertAuditAction.GET,
id,
})
);
if (alert == null || alert.hits.hits.length === 0) {
const errorMessage = `Unable to retrieve alert details for alert with id of "${id}" and operation ${ReadOperations.Get}`;
this.logger.error(errorMessage);
throw Boom.notFound(errorMessage);
}
return alert;
// move away from pulling data from _source in the future
return alert.hits.hits[0]._source;
} catch (error) {
this.logger.debug(`Error fetching alert with id of "${id}"`);
this.auditLogger?.log(
alertAuditEvent({
action: AlertAuditAction.GET,
id,
error,
})
);
this.logger.error(`get threw an error: ${error}`);
throw error;
}
}
@ -189,29 +444,19 @@ export class AlertsClient {
index,
}: UpdateOptions<Params>) {
try {
const alert = await this.fetchAlert({
const alert = await this.singleSearchAfterAndAudit({
id,
query: null,
index,
});
if (alert == null) {
return;
}
await this.authorization.ensureAuthorized({
ruleTypeId: alert[RULE_ID],
consumer: alert[ALERT_OWNER],
operation: WriteOperations.Update,
entity: AlertingAuthorizationEntity.Alert,
lastSortIds: undefined,
});
this.auditLogger?.log(
alertAuditEvent({
action: AlertAuditAction.UPDATE,
id,
outcome: 'unknown',
})
);
if (alert == null || alert.hits.hits.length === 0) {
const errorMessage = `Unable to retrieve alert details for alert with id of "${id}" and operation ${ReadOperations.Get}`;
this.logger.error(errorMessage);
throw Boom.notFound(errorMessage);
}
const { body: response } = await this.esClient.update<ParsedTechnicalFields>({
...decodeVersion(_version),
@ -230,39 +475,97 @@ export class AlertsClient {
_version: encodeHitVersion(response),
};
} catch (error) {
this.auditLogger?.log(
alertAuditEvent({
action: AlertAuditAction.UPDATE,
id,
error,
})
);
this.logger.error(`update threw an error: ${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
);
public async bulkUpdate<Params extends AlertTypeParams = never>({
ids,
query,
index,
status,
}: BulkUpdateOptions<Params>) {
// rejects at the route level if more than 1000 id's are passed in
if (ids != null) {
return this.mgetAlertsAuditOperate({
ids,
status,
indexName: index,
operation: WriteOperations.Update,
});
} else if (query != null) {
try {
// execute search after with query + authorization filter
// audit results of that query
const fetchAndAuditResponse = await this.queryAndAuditAllAlerts({
query,
index,
operation: WriteOperations.Update,
});
// 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);
}
if (!fetchAndAuditResponse?.auditedAlerts) {
throw Boom.unauthorized('Failed to audit alerts');
}
const toReturn = Array.from(authorizedFeatures).flatMap((feature) => {
if (isValidFeatureId(feature)) {
return mapConsumerToIndexName[feature];
// executes updateByQuery with query + authorization filter
// used in the queryAndAuditAllAlerts function
const result = await this.esClient.updateByQuery({
index,
conflicts: 'proceed',
refresh: true,
body: {
script: {
source: `if (ctx._source['${ALERT_STATUS}'] != null) {
ctx._source['${ALERT_STATUS}'] = '${status}'
}
if (ctx._source['signal.status'] != null) {
ctx._source['signal.status'] = '${status}'
}`,
lang: 'painless',
} as InlineScript,
query: fetchAndAuditResponse.authorizedQuery as Omit<QueryDslQueryContainer, 'script'>,
},
ignore_unavailable: true,
});
return result;
} catch (err) {
this.logger.error(`bulkUpdate threw an error: ${err}`);
throw err;
}
return [];
});
} else {
throw Boom.badRequest('no ids or query were provided for updating');
}
}
return toReturn;
public async getAuthorizedAlertsIndices(featureIds: string[]): Promise<string[] | undefined> {
try {
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;
} catch (exc) {
const errMessage = `getAuthorizedAlertsIndices failed to get authorized rule types: ${exc}`;
this.logger.error(errMessage);
throw Boom.failedDependency(errMessage);
}
}
}

View file

@ -7,6 +7,7 @@
import { EcsEventOutcome, EcsEventType } from 'src/core/server';
import { AuditEvent } from '../../../security/server';
import { ReadOperations, WriteOperations } from '../../../alerting/server';
export enum AlertAuditAction {
GET = 'alert_get',
@ -14,6 +15,12 @@ export enum AlertAuditAction {
FIND = 'alert_find',
}
export const operationAlertAuditActionMap = {
[WriteOperations.Update]: AlertAuditAction.UPDATE,
[ReadOperations.Find]: AlertAuditAction.FIND,
[ReadOperations.Get]: AlertAuditAction.GET,
};
type VerbsTuple = [string, string, string];
const eventVerbs: Record<AlertAuditAction, VerbsTuple> = {

View file

@ -0,0 +1,458 @@
/*
* 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 { ALERT_OWNER, ALERT_STATUS, SPACE_IDS, RULE_ID } from '@kbn/rule-data-utils';
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';
import { AlertingAuthorizationEntity } from '../../../../alerting/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,
};
const DEFAULT_SPACE = 'test_default_space_id';
beforeEach(() => {
jest.resetAllMocks();
alertingAuthMock.getSpaceId.mockImplementation(() => 'test_default_space_id');
// @ts-expect-error
alertingAuthMock.getAuthorizationFilter.mockImplementation(async () =>
Promise.resolve({ filter: [] })
);
alertingAuthMock.ensureAuthorized.mockImplementation(
// @ts-expect-error
async ({
ruleTypeId,
consumer,
operation,
entity,
}: {
ruleTypeId: string;
consumer: string;
operation: string;
entity: typeof AlertingAuthorizationEntity.Alert;
}) => {
if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') {
return Promise.resolve();
}
return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`));
}
);
});
const fakeAlertId = 'myfakeid1';
const successfulAuthzHit = 'successfulAuthzHit';
const unsuccessfulAuthzHit = 'unsuccessfulAuthzHit';
// fakeRuleTypeId will cause authz to fail
const fakeRuleTypeId = 'fake.rule';
describe('bulkUpdate()', () => {
describe('ids', () => {
describe('audit log', () => {
test('logs successful event in audit logger', async () => {
const indexName = '.alerts-observability-apm.alerts';
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.mget.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
docs: [
{
_id: fakeAlertId,
_index: indexName,
_source: {
[RULE_ID]: 'apm.error_rate',
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
},
})
);
esClientMock.bulk.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
errors: false,
took: 1,
items: [
{
update: {
_id: fakeAlertId,
_index: '.alerts-observability-apm.alerts',
result: 'updated',
status: 200,
},
},
],
},
})
);
await alertsClient.bulkUpdate({
ids: [fakeAlertId],
query: undefined,
index: indexName,
status: 'closed',
});
expect(auditLogger.log).toHaveBeenNthCalledWith(1, {
message: `User is updating alert [id=${fakeAlertId}]`,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'unknown',
type: ['change'],
},
error: undefined,
});
});
test('audit error access if user is unauthorized for given alert', async () => {
const indexName = '.alerts-observability-apm.alerts';
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.mget.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
docs: [
{
_id: fakeAlertId,
_index: indexName,
_source: {
[RULE_ID]: fakeRuleTypeId,
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
},
})
);
await expect(
alertsClient.bulkUpdate({
ids: [fakeAlertId],
query: undefined,
index: indexName,
status: 'closed',
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Unauthorized for fake.rule and apm"`);
expect(auditLogger.log).toHaveBeenNthCalledWith(1, {
message: `Failed attempt to update alert [id=${fakeAlertId}]`,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'failure',
type: ['change'],
},
error: {
code: 'Error',
message: 'Unauthorized for fake.rule and apm',
},
});
});
test('logs multiple error events in audit logger', async () => {
const indexName = '.alerts-observability-apm.alerts';
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.mget.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
docs: [
{
_id: successfulAuthzHit,
_index: indexName,
_source: {
[RULE_ID]: 'apm.error_rate',
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
{
_id: unsuccessfulAuthzHit,
_index: indexName,
_source: {
[RULE_ID]: fakeRuleTypeId,
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
},
})
);
await expect(
alertsClient.bulkUpdate({
ids: [successfulAuthzHit, unsuccessfulAuthzHit],
query: undefined,
index: indexName,
status: 'closed',
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Unauthorized for fake.rule and apm"`);
expect(auditLogger.log).toHaveBeenCalledTimes(2);
expect(auditLogger.log).toHaveBeenNthCalledWith(1, {
message: `Failed attempt to update alert [id=${unsuccessfulAuthzHit}]`,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'failure',
type: ['change'],
},
error: {
code: 'Error',
message: 'Unauthorized for fake.rule and apm',
},
});
expect(auditLogger.log).toHaveBeenNthCalledWith(2, {
message: `Failed attempt to update alert [id=${successfulAuthzHit}]`,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'failure',
type: ['change'],
},
error: {
code: 'Error',
message: 'Unauthorized for fake.rule and apm',
},
});
});
});
// test('throws an error if ES client fetch fails', async () => {});
// test('throws an error if ES client bulk update fails', async () => {});
// test('throws an error if ES client updateByQuery fails', async () => {});
});
describe('query', () => {
describe('audit log', () => {
test('logs successful event in audit logger', async () => {
const indexName = '.alerts-observability-apm.alerts';
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: [
{
_id: fakeAlertId,
_index: '.alerts-observability-apm.alerts',
_source: {
[RULE_ID]: 'apm.error_rate',
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
},
},
})
);
esClientMock.updateByQuery.mockResolvedValueOnce(
elasticsearchClientMock.createApiResponse({
body: {
updated: 1,
},
})
);
await alertsClient.bulkUpdate({
ids: undefined,
query: `${ALERT_STATUS}: open`,
index: indexName,
status: 'closed',
});
expect(auditLogger.log).toHaveBeenCalledWith({
message: `User is updating alert [id=${fakeAlertId}]`,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'unknown',
type: ['change'],
},
error: undefined,
});
});
test('audit error access if user is unauthorized for given alert', async () => {
const indexName = '.alerts-observability-apm.alerts';
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: [
{
_id: fakeAlertId,
_index: '.alerts-observability-apm.alerts',
_source: {
[RULE_ID]: fakeRuleTypeId,
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
},
},
})
);
await expect(
alertsClient.bulkUpdate({
ids: undefined,
query: `${ALERT_STATUS}: open`,
index: indexName,
status: 'closed',
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"queryAndAuditAllAlerts threw an error: Unable to retrieve alerts with query \\"kibana.alert.status: open\\" and operation update
Error: Unable to retrieve alert details for alert with id of \\"null\\" or with query \\"kibana.alert.status: open\\" and operation update
Error: Error: Unauthorized for fake.rule and apm"
`);
expect(auditLogger.log).toHaveBeenNthCalledWith(1, {
message: `Failed attempt to update alert [id=${fakeAlertId}]`,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'failure',
type: ['change'],
},
error: {
code: 'Error',
message: 'Unauthorized for fake.rule and apm',
},
});
});
test('logs multiple error events in audit logger', async () => {
const indexName = '.alerts-observability-apm.alerts';
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: 2,
max_score: 999,
hits: [
{
_id: successfulAuthzHit,
_index: '.alerts-observability-apm.alerts',
_source: {
[RULE_ID]: 'apm.error_rate',
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
{
_id: unsuccessfulAuthzHit,
_index: '.alerts-observability-apm.alerts',
_source: {
[RULE_ID]: fakeRuleTypeId,
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
},
},
})
);
await expect(
alertsClient.bulkUpdate({
ids: undefined,
query: `${ALERT_STATUS}: open`,
index: indexName,
status: 'closed',
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"queryAndAuditAllAlerts threw an error: Unable to retrieve alerts with query \\"kibana.alert.status: open\\" and operation update
Error: Unable to retrieve alert details for alert with id of \\"null\\" or with query \\"kibana.alert.status: open\\" and operation update
Error: Error: Unauthorized for fake.rule and apm"
`);
expect(auditLogger.log).toHaveBeenCalledTimes(2);
expect(auditLogger.log).toHaveBeenNthCalledWith(1, {
message: `Failed attempt to update alert [id=${unsuccessfulAuthzHit}]`,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'failure',
type: ['change'],
},
error: {
code: 'Error',
message: 'Unauthorized for fake.rule and apm',
},
});
expect(auditLogger.log).toHaveBeenNthCalledWith(2, {
message: `Failed attempt to update alert [id=${successfulAuthzHit}]`,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'failure',
type: ['change'],
},
error: {
code: 'Error',
message: 'Unauthorized for fake.rule and apm',
},
});
});
});
// test('throws an error if ES client fetch fails', async () => {});
// test('throws an error if ES client bulk update fails', async () => {});
// test('throws an error if ES client updateByQuery fails', async () => {});
});
});

View file

@ -5,13 +5,14 @@
* 2.0.
*/
import { ALERT_OWNER, ALERT_STATUS, SPACE_IDS } from '@kbn/rule-data-utils';
import { ALERT_OWNER, ALERT_STATUS, RULE_ID, SPACE_IDS } from '@kbn/rule-data-utils';
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';
import { AlertingAuthorizationEntity } from '../../../../alerting/server';
const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
@ -26,9 +27,35 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
auditLogger,
};
const DEFAULT_SPACE = 'test_default_space_id';
beforeEach(() => {
jest.resetAllMocks();
alertingAuthMock.getSpaceId.mockImplementation(() => 'test_default_space_id');
alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE);
// @ts-expect-error
alertingAuthMock.getAuthorizationFilter.mockImplementation(async () =>
Promise.resolve({ filter: [] })
);
alertingAuthMock.ensureAuthorized.mockImplementation(
// @ts-expect-error
async ({
ruleTypeId,
consumer,
operation,
entity,
}: {
ruleTypeId: string;
consumer: string;
operation: string;
entity: typeof AlertingAuthorizationEntity.Alert;
}) => {
if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') {
return Promise.resolve();
}
return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`));
}
);
});
describe('get()', () => {
@ -73,10 +100,9 @@ describe('get()', () => {
const result = await alertsClient.get({ id: '1', index: '.alerts-observability-apm' });
expect(result).toMatchInlineSnapshot(`
Object {
"_version": "WzM2MiwyXQ==",
"${ALERT_OWNER}": "apm",
"${ALERT_STATUS}": "open",
"${SPACE_IDS}": Array [
"kibana.alert.owner": "apm",
"kibana.alert.status": "open",
"kibana.space_ids": Array [
"test_default_space_id",
],
"message": "hello world 1",
@ -92,18 +118,37 @@ describe('get()', () => {
"bool": Object {
"filter": Array [
Object {
"term": Object {
"_id": "1",
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"_id": "1",
},
},
],
},
},
Object {},
Object {
"term": Object {
"kibana.space_ids": "test_default_space_id",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
"sort": Array [
Object {
"@timestamp": Object {
"order": "asc",
"unmapped_type": "date",
},
},
],
},
"ignore_unavailable": true,
"index": ".alerts-observability-apm",
@ -151,12 +196,75 @@ describe('get()', () => {
},
})
);
await alertsClient.get({ id: '1', index: '.alerts-observability-apm' });
await alertsClient.get({ id: 'NoxgpHkBqbdrfX07MqXV', 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]',
message: 'User has accessed alert [id=NoxgpHkBqbdrfX07MqXV]',
});
});
test('audit error access if user is unauthorized for given alert', async () => {
const indexName = '.alerts-observability-apm.alerts';
const fakeAlertId = 'myfakeid1';
// fakeRuleTypeId will cause authz to fail
const fakeRuleTypeId = 'fake.rule';
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',
_version: 1,
_seq_no: 362,
_primary_term: 2,
_id: fakeAlertId,
_index: indexName,
_source: {
[RULE_ID]: fakeRuleTypeId,
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
},
},
})
);
await expect(alertsClient.get({ id: fakeAlertId, index: '.alerts-observability-apm' })).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Unable to retrieve alert details for alert with id of \\"myfakeid1\\" or with query \\"null\\" and operation get
Error: Error: Unauthorized for fake.rule and apm"
`);
expect(auditLogger.log).toHaveBeenNthCalledWith(1, {
message: `Failed attempt to access alert [id=${fakeAlertId}]`,
event: {
action: 'alert_get',
category: ['database'],
outcome: 'failure',
type: ['access'],
},
error: {
code: 'Error',
message: 'Unauthorized for fake.rule and apm',
},
});
});
@ -166,13 +274,11 @@ describe('get()', () => {
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]',
});
alertsClient.get({ id: 'NoxgpHkBqbdrfX07MqXV', index: '.alerts-observability-apm' })
).rejects.toThrowErrorMatchingInlineSnapshot(`
"Unable to retrieve alert details for alert with id of \\"NoxgpHkBqbdrfX07MqXV\\" or with query \\"null\\" and operation get
Error: Error: something went wrong"
`);
});
describe('authorization', () => {
@ -217,20 +323,16 @@ describe('get()', () => {
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',
const result = await alertsClient.get({
id: 'NoxgpHkBqbdrfX07MqXV',
index: '.alerts-observability-apm',
});
expect(result).toMatchInlineSnapshot(`
Object {
"_version": "WzM2MiwyXQ==",
"${ALERT_OWNER}": "apm",
"${ALERT_STATUS}": "open",
"${SPACE_IDS}": Array [
"kibana.alert.owner": "apm",
"kibana.alert.status": "open",
"kibana.space_ids": Array [
"test_default_space_id",
],
"message": "hello world 1",
@ -238,25 +340,5 @@ describe('get()', () => {
}
`);
});
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

@ -5,13 +5,14 @@
* 2.0.
*/
import { ALERT_OWNER, ALERT_STATUS, SPACE_IDS } from '@kbn/rule-data-utils';
import { ALERT_OWNER, ALERT_STATUS, SPACE_IDS, RULE_ID } from '@kbn/rule-data-utils';
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';
import { AlertingAuthorizationEntity } from '../../../../alerting/server';
const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
@ -26,9 +27,35 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
auditLogger,
};
const DEFAULT_SPACE = 'test_default_space_id';
beforeEach(() => {
jest.resetAllMocks();
alertingAuthMock.getSpaceId.mockImplementation(() => 'test_default_space_id');
alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE);
// @ts-expect-error
alertingAuthMock.getAuthorizationFilter.mockImplementation(async () =>
Promise.resolve({ filter: [] })
);
alertingAuthMock.ensureAuthorized.mockImplementation(
// @ts-expect-error
async ({
ruleTypeId,
consumer,
operation,
entity,
}: {
ruleTypeId: string;
consumer: string;
operation: string;
entity: typeof AlertingAuthorizationEntity.Alert;
}) => {
if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') {
return Promise.resolve();
}
return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`));
}
);
});
describe('update()', () => {
@ -55,11 +82,11 @@ describe('update()', () => {
_index: '.alerts-observability-apm',
_id: 'NoxgpHkBqbdrfX07MqXV',
_source: {
'rule.id': 'apm.error_rate',
[RULE_ID]: 'apm.error_rate',
message: 'hello world 1',
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: ['test_default_space_id'],
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
@ -145,7 +172,7 @@ describe('update()', () => {
message: 'hello world 1',
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: ['test_default_space_id'],
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
@ -167,7 +194,7 @@ describe('update()', () => {
})
);
await alertsClient.update({
id: '1',
id: 'NoxgpHkBqbdrfX07MqXV',
status: 'closed',
_version: undefined,
index: '.alerts-observability-apm',
@ -181,35 +208,97 @@ describe('update()', () => {
outcome: 'unknown',
type: ['change'],
},
message: 'User is updating alert [id=1]',
message: 'User is updating alert [id=NoxgpHkBqbdrfX07MqXV]',
});
});
test(`throws an error if ES client get fails`, async () => {
const error = new Error('something went wrong on get');
test('audit error update if user is unauthorized for given alert', async () => {
const indexName = '.alerts-observability-apm.alerts';
const fakeAlertId = 'myfakeid1';
// fakeRuleTypeId will cause authz to fail
const fakeRuleTypeId = 'fake.rule';
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockRejectedValue(error);
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',
_version: 1,
_seq_no: 362,
_primary_term: 2,
_id: fakeAlertId,
_index: indexName,
_source: {
[RULE_ID]: fakeRuleTypeId,
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
},
},
})
);
await expect(
alertsClient.update({
id: '1',
id: fakeAlertId,
status: 'closed',
_version: undefined,
_version: '1',
index: '.alerts-observability-apm',
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong on get"`);
expect(auditLogger.log).toHaveBeenCalledWith({
error: { code: 'Error', message: 'something went wrong on get' },
).rejects.toThrowErrorMatchingInlineSnapshot(`
"Unable to retrieve alert details for alert with id of \\"myfakeid1\\" or with query \\"null\\" and operation update
Error: Error: Unauthorized for fake.rule and apm"
`);
expect(auditLogger.log).toHaveBeenNthCalledWith(1, {
message: `Failed attempt to update alert [id=${fakeAlertId}]`,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'failure',
type: ['change'],
},
message: 'Failed attempt to update alert [id=1]',
error: {
code: 'Error',
message: 'Unauthorized for fake.rule and apm',
},
});
});
test(`throws an error if ES client get fails`, async () => {
const error = new Error('something went wrong on update');
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockRejectedValue(error);
await expect(
alertsClient.update({
id: 'NoxgpHkBqbdrfX07MqXV',
status: 'closed',
_version: undefined,
index: '.alerts-observability-apm',
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"Unable to retrieve alert details for alert with id of \\"NoxgpHkBqbdrfX07MqXV\\" or with query \\"null\\" and operation update
Error: Error: something went wrong on update"
`);
});
test(`throws an error if ES client update fails`, async () => {
const error = new Error('something went wrong on update');
const alertsClient = new AlertsClient(alertsClientParams);
@ -238,7 +327,7 @@ describe('update()', () => {
message: 'hello world 1',
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: ['test_default_space_id'],
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
@ -250,21 +339,21 @@ describe('update()', () => {
await expect(
alertsClient.update({
id: '1',
id: 'NoxgpHkBqbdrfX07MqXV',
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' },
error: undefined,
event: {
action: 'alert_update',
category: ['database'],
outcome: 'failure',
outcome: 'unknown',
type: ['change'],
},
message: 'Failed attempt to update alert [id=1]',
message: 'User is updating alert [id=NoxgpHkBqbdrfX07MqXV]',
});
});
@ -298,7 +387,7 @@ describe('update()', () => {
message: 'hello world 1',
[ALERT_OWNER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: ['test_default_space_id'],
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
],
@ -325,18 +414,12 @@ describe('update()', () => {
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',
id: 'NoxgpHkBqbdrfX07MqXV',
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",
@ -353,30 +436,5 @@ describe('update()', () => {
}
`);
});
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

@ -0,0 +1,93 @@
/*
* 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 bulkUpdateAlertsRoute = (router: IRouter<RacRequestHandlerContext>) => {
router.post(
{
path: `${BASE_RAC_ALERTS_API_PATH}/bulk_update`,
validate: {
body: buildRouteValidation(
t.union([
t.strict({
status: t.union([t.literal('open'), t.literal('closed')]),
index: t.string,
ids: t.array(t.string),
query: t.undefined,
}),
t.strict({
status: t.union([t.literal('open'), t.literal('closed')]),
index: t.string,
ids: t.undefined,
query: t.string,
}),
])
),
},
options: {
tags: ['access:rac'],
},
},
async (context, req, response) => {
try {
const alertsClient = await context.rac.getAlertsClient();
const { status, ids, index, query } = req.body;
if (ids != null && ids.length > 1000) {
return response.badRequest({
body: {
message: 'cannot use more than 1000 ids',
},
});
}
const updatedAlert = await alertsClient.bulkUpdate({
ids,
status,
query,
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

@ -10,9 +10,11 @@ import { RacRequestHandlerContext } from '../types';
import { getAlertByIdRoute } from './get_alert_by_id';
import { updateAlertByIdRoute } from './update_alert_by_id';
import { getAlertsIndexRoute } from './get_alert_index';
import { bulkUpdateAlertsRoute } from './bulk_update_alerts';
export function defineRoutes(router: IRouter<RacRequestHandlerContext>) {
getAlertByIdRoute(router);
updateAlertByIdRoute(router);
getAlertsIndexRoute(router);
bulkUpdateAlertsRoute(router);
}

View file

@ -0,0 +1,30 @@
#!/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:-[\"Do4JnHoBqkRSppNZ6vre\"]}
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/bulk_update \
-d "{\"ids\": $IDS, \"status\":\"$STATUS\", \"index\":\".alerts-observability-apm\"}" | jq .
# -d "{\"ids\": $IDS, \"query\": \"kibana.rac.alert.status: open\", \"status\":\"$STATUS\", \"index\":\".alerts-observability-apm\"}" | jq .
# -d "{\"ids\": $IDS, \"status\":\"$STATUS\", \"index\":\".alerts-observability-apm\"}" | jq .

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
QUERY=${1:-"kibana.rac.alert.status: open"}
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 "kibana.rac.alert.stats: open" <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/bulk_update \
-d "{\"query\": \"$QUERY\", \"status\":\"$STATUS\", \"index\":\".alerts-observability-apm\"}" | jq .

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
QUERY=${1:-"signal.status: open"}
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 "kibana.rac.alert.stats: open" <closed | open>
curl -s -k \
-H 'Content-Type: application/json' \
-H 'kbn-xsrf: 123' \
-u hunter:changeme \
-X POST ${KIBANA_URL}${SPACE_URL}/internal/rac/alerts/bulk_update \
-d "{\"query\": \"$QUERY\", \"status\":\"$STATUS\", \"index\":\".siem-signals*\"}" | jq .

View file

@ -10,7 +10,7 @@
set -e
USER=${1:-'observer'}
ID=${2:-'DHEnOXoB8br9Z2X1fq_l'}
ID=${2:-'Do4JnHoBqkRSppNZ6vre'}
cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh
cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh

View file

@ -6,12 +6,7 @@
"kibana": [
{
"feature": {
"ml": ["read"],
"siem": ["all"],
"actions": ["read"],
"ruleRegistry": ["all"],
"builtInAlerts": ["all"],
"alerting": ["all"]
"siem": ["all"]
},
"spaces": ["*"]
}

View file

@ -6,7 +6,7 @@
"kibana": [
{
"feature": {
"apm": ["read", "alerts_all"]
"apm": ["all"]
},
"spaces": ["*"]
}

View file

@ -18,6 +18,9 @@ import {
* @deprecated ruleExecutionFieldMap is kept here only as a reference. It will be superseded with EventLog implementation
*/
export const ruleExecutionFieldMap = {
// [ALERT_OWNER]: { type: 'keyword', required: true },
// [SPACE_IDS]: { type: 'keyword', array: true, required: true },
// [RULE_ID]: { type: 'keyword', required: true },
[MESSAGE]: { type: 'keyword' },
[EVENT_SEQUENCE]: { type: 'long' },
[EVENT_END]: { type: 'date' },

View file

@ -213,6 +213,14 @@ export class RuleRegistryLogClient implements IRuleRegistryLogClient {
);
}
// { [x: string]: string | string[] | ExecutionMetricValue<T>;
// [x: number]: string;
// "kibana.space_ids": string[];
// "event.action": T;
// "event.kind": string;
// "rule.id": string;
// "@timestamp": string; }
public async logExecutionMetric<T extends ExecutionMetric>({
ruleId,
namespace,

View file

@ -82,3 +82,35 @@
}
}
}
{
"type": "doc",
"value": {
"index": ".alerts-security.alerts",
"id": "space1securityalert",
"source": {
"@timestamp": "2020-12-16T15:16:18.570Z",
"rule.id": "siem.signals",
"message": "hello world security",
"kibana.alert.owner": "siem",
"kibana.alert.status": "open",
"kibana.space_ids": ["space1"]
}
}
}
{
"type": "doc",
"value": {
"index": ".alerts-security.alerts",
"id": "space2securityalert",
"source": {
"@timestamp": "2020-12-16T15:16:18.570Z",
"rule.id": "siem.signals",
"message": "hello world security",
"kibana.alert.owner": "siem",
"kibana.alert.status": "open",
"kibana.space_ids": ["space2"]
}
}
}

View file

@ -0,0 +1,225 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import {
superUser,
globalRead,
obsOnly,
obsOnlyRead,
obsSec,
obsSecRead,
secOnly,
secOnlyRead,
secOnlySpace2,
secOnlyReadSpace2,
obsSecAllSpace2,
obsSecReadSpace2,
obsOnlySpace2,
obsOnlyReadSpace2,
obsOnlySpacesAll,
obsSecSpacesAll,
secOnlySpacesAll,
noKibanaPrivileges,
} from '../../../common/lib/authentication/users';
import type { User } from '../../../common/lib/authentication/types';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces';
interface TestCase {
/** The space where the alert exists */
space: string;
/** The ID of the alert */
alertId: string;
/** The index of the alert */
index: string;
/** Authorized users */
authorizedUsers: User[];
/** Unauthorized users */
unauthorizedUsers: User[];
}
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const supertestWithoutAuth = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
const TEST_URL = '/internal/rac/alerts';
const ALERTS_INDEX_URL = `${TEST_URL}/index`;
const SPACE1 = 'space1';
const SPACE2 = 'space2';
const APM_ALERT_ID = 'NoxgpHkBqbdrfX07MqXV';
const APM_ALERT_INDEX = '.alerts-observability-apm';
const SECURITY_SOLUTION_ALERT_ID = '020202';
const SECURITY_SOLUTION_ALERT_INDEX = '.alerts-security.alerts';
// const ALERT_VERSION = Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'); // required for optimistic concurrency control
const getAPMIndexName = async (user: User) => {
const {
body: indexNames,
}: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth
.get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`)
.auth(user.username, user.password)
.set('kbn-xsrf', 'true')
.expect(200);
const observabilityIndex = indexNames?.index_name?.find(
(indexName) => indexName === APM_ALERT_INDEX
);
expect(observabilityIndex).to.eql(APM_ALERT_INDEX); // assert this here so we can use constants in the dynamically-defined test cases below
};
const getSecuritySolutionIndexName = async (user: User) => {
const {
body: indexNames,
}: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth
.get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`)
.auth(user.username, user.password)
.set('kbn-xsrf', 'true')
.expect(200);
const securitySolution = indexNames?.index_name?.find(
(indexName) => indexName === SECURITY_SOLUTION_ALERT_INDEX
);
expect(securitySolution).to.eql(SECURITY_SOLUTION_ALERT_INDEX); // assert this here so we can use constants in the dynamically-defined test cases below
};
describe('Alert - Bulk Update - RBAC - spaces', () => {
before(async () => {
await getSecuritySolutionIndexName(superUser);
await getAPMIndexName(superUser);
});
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts');
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts');
});
function addTests({ space, authorizedUsers, unauthorizedUsers, alertId, index }: TestCase) {
authorizedUsers.forEach(({ username, password }) => {
it(`${username} should bulk update alert with given id ${alertId} in ${space}/${index}`, async () => {
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); // since this is a success case, reload the test data immediately beforehand
const { body: updated } = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(space)}${TEST_URL}/bulk_update`)
.auth(username, password)
.set('kbn-xsrf', 'true')
.send({
ids: [alertId],
status: 'closed',
index,
});
expect(updated.statusCode).to.eql(200);
const items = updated.body.items;
// @ts-expect-error
items.map((item) => expect(item.update.result).to.eql('updated'));
});
it(`${username} should bulk update alerts which match query in ${space}/${index}`, async () => {
const { body: updated } = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(space)}${TEST_URL}/bulk_update`)
.auth(username, password)
.set('kbn-xsrf', 'true')
.send({
status: 'closed',
query: 'kibana.alert.status: open',
index,
});
expect(updated.statusCode).to.eql(200);
expect(updated.body.updated).to.greaterThan(0);
});
});
unauthorizedUsers.forEach(({ username, password }) => {
it(`${username} should NOT be able to update alert ${alertId} in ${space}/${index}`, async () => {
const res = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(space)}${TEST_URL}/bulk_update`)
.auth(username, password)
.set('kbn-xsrf', 'true')
.send({
ids: [alertId],
status: 'closed',
index,
});
expect([403, 404]).to.contain(res.statusCode);
});
});
}
// Alert - Update - RBAC - spaces Security Solution superuser should bulk update alerts which match query in space1/.alerts-security.alerts
// Alert - Update - RBAC - spaces superuser should bulk update alert with given id 020202 in space1/.alerts-security.alerts
describe('Security Solution', () => {
const authorizedInAllSpaces = [superUser, secOnlySpacesAll, obsSecSpacesAll];
const authorizedOnlyInSpace1 = [secOnly, obsSec];
const authorizedOnlyInSpace2 = [secOnlySpace2, obsSecAllSpace2];
const unauthorized = [
// these users are not authorized to update alerts for the Security Solution in any space
globalRead,
secOnlyRead,
obsSecRead,
secOnlyReadSpace2,
obsSecReadSpace2,
obsOnly,
obsOnlyRead,
obsOnlySpace2,
obsOnlyReadSpace2,
obsOnlySpacesAll,
noKibanaPrivileges,
];
addTests({
space: SPACE1,
alertId: SECURITY_SOLUTION_ALERT_ID,
index: SECURITY_SOLUTION_ALERT_INDEX,
authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace1],
unauthorizedUsers: [...authorizedOnlyInSpace2, ...unauthorized],
});
addTests({
space: SPACE2,
alertId: SECURITY_SOLUTION_ALERT_ID,
index: SECURITY_SOLUTION_ALERT_INDEX,
authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace2],
unauthorizedUsers: [...authorizedOnlyInSpace1, ...unauthorized],
});
});
describe('APM', () => {
const authorizedInAllSpaces = [superUser, obsOnlySpacesAll, obsSecSpacesAll];
const authorizedOnlyInSpace1 = [obsOnly, obsSec];
const authorizedOnlyInSpace2 = [obsOnlySpace2, obsSecAllSpace2];
const unauthorized = [
// these users are not authorized to update alerts for APM in any space
globalRead,
obsOnlyRead,
obsSecRead,
obsOnlyReadSpace2,
obsSecReadSpace2,
secOnly,
secOnlyRead,
secOnlySpace2,
secOnlyReadSpace2,
secOnlySpacesAll,
noKibanaPrivileges,
];
addTests({
space: SPACE1,
alertId: APM_ALERT_ID,
index: APM_ALERT_INDEX,
authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace1],
unauthorizedUsers: [...authorizedOnlyInSpace2, ...unauthorized],
});
addTests({
space: SPACE2,
alertId: APM_ALERT_ID,
index: APM_ALERT_INDEX,
authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace2],
unauthorizedUsers: [...authorizedOnlyInSpace1, ...unauthorized],
});
});
});
};

View file

@ -152,11 +152,11 @@ export default ({ getService }: FtrProviderContext) => {
unauthorizedUsers.forEach(({ username, password }) => {
it(`${username} should NOT be able to access alert ${alertId} in ${space}/${index}`, async () => {
await supertestWithoutAuth
const res = await supertestWithoutAuth
.get(`${getSpaceUrlPrefix(space)}${TEST_URL}?id=${alertId}&index=${index}`)
.auth(username, password)
.set('kbn-xsrf', 'true')
.expect(403);
.set('kbn-xsrf', 'true');
expect([403, 404]).to.contain(res.statusCode);
});
});
}

View file

@ -25,5 +25,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => {
// Basic
loadTestFile(require.resolve('./get_alert_by_id'));
loadTestFile(require.resolve('./update_alert'));
loadTestFile(require.resolve('./bulk_update_alerts'));
});
};

View file

@ -145,25 +145,11 @@ export default ({ getService }: FtrProviderContext) => {
})
.expect(404);
});
it(`${username} should return a 404 when superuser accesses not-existent alerts as data index`, async () => {
await supertestWithoutAuth
.get(`${getSpaceUrlPrefix(space)}${TEST_URL}?id=${APM_ALERT_ID}&index=myfakeindex`)
.auth(username, password)
.set('kbn-xsrf', 'true')
.send({
ids: [APM_ALERT_ID],
status: 'closed',
index: 'this index does not exist',
_version: ALERT_VERSION,
})
.expect(404);
});
});
unauthorizedUsers.forEach(({ username, password }) => {
it(`${username} should NOT be able to update alert ${alertId} in ${space}/${index}`, async () => {
await supertestWithoutAuth
const res = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(space)}${TEST_URL}`)
.auth(username, password)
.set('kbn-xsrf', 'true')
@ -172,8 +158,8 @@ export default ({ getService }: FtrProviderContext) => {
status: 'closed',
index,
_version: ALERT_VERSION,
})
.expect(403);
});
expect([403, 404]).to.contain(res.statusCode);
});
});
}

View file

@ -73,7 +73,7 @@ export default ({ getService }: FtrProviderContext) => {
.get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`)
.auth(obsMinRead.username, obsMinRead.password)
.set('kbn-xsrf', 'true')
.expect(403);
.expect(404);
});
it(`${obsMinReadSpacesAll.username} should NOT be able to access the APM alert in ${SPACE1}`, async () => {
@ -82,7 +82,7 @@ export default ({ getService }: FtrProviderContext) => {
.get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`)
.auth(obsMinReadSpacesAll.username, obsMinReadSpacesAll.password)
.set('kbn-xsrf', 'true')
.expect(403);
.expect(404);
});
});

View file

@ -166,7 +166,7 @@ export default ({ getService }: FtrProviderContext) => {
index: apmIndex,
_version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'),
})
.expect(403);
.expect(404);
});
it(`${obsMinAllSpacesAll.username} should NOT be able to update the APM alert in ${SPACE1}`, async () => {
@ -181,7 +181,7 @@ export default ({ getService }: FtrProviderContext) => {
index: apmIndex,
_version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'),
})
.expect(403);
.expect(404);
});
});
});