diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index 31779c9f08e81..6c45403fc0a13 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -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, diff --git a/x-pack/plugins/lists/server/services/utils/decode_version.ts b/packages/kbn-securitysolution-es-utils/src/decode_version/index.ts similarity index 85% rename from x-pack/plugins/lists/server/services/utils/decode_version.ts rename to packages/kbn-securitysolution-es-utils/src/decode_version/index.ts index 8ed934204ed98..d58c7add67a27 100644 --- a/x-pack/plugins/lists/server/services/utils/decode_version.ts +++ b/packages/kbn-securitysolution-es-utils/src/decode_version/index.ts @@ -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 diff --git a/x-pack/plugins/lists/server/services/utils/encode_hit_version.ts b/packages/kbn-securitysolution-es-utils/src/encode_hit_version/index.ts similarity index 84% rename from x-pack/plugins/lists/server/services/utils/encode_hit_version.ts rename to packages/kbn-securitysolution-es-utils/src/encode_hit_version/index.ts index 4c55d858d283b..29b5a18f7c303 100644 --- a/x-pack/plugins/lists/server/services/utils/encode_hit_version.ts +++ b/packages/kbn-securitysolution-es-utils/src/encode_hit_version/index.ts @@ -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. */ /** diff --git a/packages/kbn-securitysolution-es-utils/src/index.ts b/packages/kbn-securitysolution-es-utils/src/index.ts index cfa6820e9aac5..8dead7f899ba2 100644 --- a/packages/kbn-securitysolution-es-utils/src/index.ts +++ b/packages/kbn-securitysolution-es-utils/src/index.ts @@ -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'; diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.mock.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.mock.ts index 4e4cd4419a5a2..5e3dd2019d0a0 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.mock.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.mock.ts @@ -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>; } = { create: createAlertingAuthorizationMock, }; diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts index c07148f03c684..4b1fc7f1a7ccb 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts @@ -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 + > = 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 + > = 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", + } + `); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index 52cef9a402e35..50a1b9d84ff6d 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -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, + authorizationEntity: AlertingAuthorizationEntity + ): Promise<{ + username?: string; + hasAllRequested: boolean; + authorizedRuleTypes: Set; + }> { + 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, operations: Array, - authorizationEntity: AlertingAuthorizationEntity + authorizationEntity: AlertingAuthorizationEntity, + featuresIds?: Set ): Promise<{ username?: string; hasAllRequested: boolean; authorizedRuleTypes: Set; }> { - 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 ), }; diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 72e3325107f31..957bd89f52f36 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -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); diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index ad233c7f6df92..9476396a7aefa 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -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, }, }; diff --git a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx index 35863d8099394..b87298c5fe8a0 100644 --- a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx @@ -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, diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index fb0610dffb92e..f3e2bba2d9789 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -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 { diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 8ec92bfa7a1b5..f14894a76edb4 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -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'; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 7548d6eba060a..35c80df2ca31c 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -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 }) => { diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index ca7806251f75e..ff202669fe1da 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -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 }) => { diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index 718ffd9c92167..36fd9c3fac58d 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -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 }) => { diff --git a/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts b/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts index 9dc22844bb629..1366503ea1428 100644 --- a/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts +++ b/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts @@ -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; @@ -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 }) => { return alertExecutor({ services, + rule: { consumer: APM_SERVER_FEATURE_ID }, params, startedAt: new Date(), }); diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 647330eade1f5..f260971c3bdcb 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -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({ diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 98c2ee47b5633..56a5950c27367 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -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<{ diff --git a/x-pack/plugins/lists/server/services/items/create_list_item.ts b/x-pack/plugins/lists/server/services/items/create_list_item.ts index b4203f000b7b9..ccdb8ab4779b6 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_item.ts @@ -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 { diff --git a/x-pack/plugins/lists/server/services/items/update_list_item.ts b/x-pack/plugins/lists/server/services/items/update_list_item.ts index c73149019f416..78651bb83d73b 100644 --- a/x-pack/plugins/lists/server/services/items/update_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/update_list_item.ts @@ -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'; diff --git a/x-pack/plugins/lists/server/services/lists/create_list.ts b/x-pack/plugins/lists/server/services/lists/create_list.ts index 6c7081d7c701e..521a38a51d6eb 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.ts @@ -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 { diff --git a/x-pack/plugins/lists/server/services/lists/update_list.ts b/x-pack/plugins/lists/server/services/lists/update_list.ts index 22235341ca075..11868a6187bbf 100644 --- a/x-pack/plugins/lists/server/services/lists/update_list.ts +++ b/x-pack/plugins/lists/server/services/lists/update_list.ts @@ -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 '.'; diff --git a/x-pack/plugins/lists/server/services/utils/index.ts b/x-pack/plugins/lists/server/services/utils/index.ts index 0cd2720bd199b..64e7c50d0e7b0 100644 --- a/x-pack/plugins/lists/server/services/utils/index.ts +++ b/x-pack/plugins/lists/server/services/utils/index.ts @@ -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'; diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts index 19177c1c2785f..5b0949d7b79b7 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts @@ -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; } diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts index 79db56f9a7fe9..65392f8c379d9 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts @@ -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 { diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index c4a0687bef497..b920f2bfacf80 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -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 { diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index d820a6c0a6f76..3e8f511eb1153 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -99,6 +99,7 @@ export class ObservabilityPlugin implements Plugin { const start = () => core.getStartServices().then(([coreStart]) => coreStart); const ruleDataClient = plugins.ruleRegistry.ruleDataService.getRuleDataClient( + 'observability', plugins.ruleRegistry.ruleDataService.getFullAssetName(), () => Promise.resolve() ); diff --git a/x-pack/plugins/rule_registry/README.md b/x-pack/plugins/rule_registry/README.md index 3fe6305a0d9f6..945b8f161eb84 100644 --- a/x-pack/plugins/rule_registry/README.md +++ b/x-pack/plugins/rule_registry/README.md @@ -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) diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts index a946e9523548c..6d70c581802c1 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts @@ -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' }, diff --git a/x-pack/plugins/rule_registry/common/constants.ts b/x-pack/plugins/rule_registry/common/constants.ts new file mode 100644 index 0000000000000..72793b1087e7b --- /dev/null +++ b/x-pack/plugins/rule_registry/common/constants.ts @@ -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'; diff --git a/x-pack/plugins/rule_registry/docs/README.md b/x-pack/plugins/rule_registry/docs/README.md new file mode 100644 index 0000000000000..a22dc1ab7e864 --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/README.md @@ -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: +and: 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 +npx yarn kbn bootstrap +node scripts/build_ts_refs.js --clean --no-cache +``` diff --git a/x-pack/plugins/rule_registry/docs/alerts_client/alerts_client_api.md b/x-pack/plugins/rule_registry/docs/alerts_client/alerts_client_api.md new file mode 100644 index 0000000000000..b94a19f8e3f38 --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/alerts_client/alerts_client_api.md @@ -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) diff --git a/x-pack/plugins/rule_registry/docs/alerts_client/classes/alertsclient.md b/x-pack/plugins/rule_registry/docs/alerts_client/classes/alertsclient.md new file mode 100644 index 0000000000000..9b639829a9f5f --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/alerts_client/classes/alertsclient.md @@ -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` + +#### 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` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `__namedParameters` | `GetAlertParams` | + +#### Returns + +`Promise` + +#### 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`\>\> + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `__namedParameters` | `GetAlertParams` | + +#### Returns + +`Promise`\>\> + +#### 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` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `featureIds` | `string`[] | + +#### Returns + +`Promise` + +#### 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**(`__namedParameters`): `Promise`<`Object`\> + +#### Type parameters + +| Name | Type | +| :------ | :------ | +| `Params` | `Params`: `AlertTypeParams` = `never` | + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `__namedParameters` | [UpdateOptions](../interfaces/updateoptions.md) | + +#### 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) diff --git a/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/constructoroptions.md b/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/constructoroptions.md new file mode 100644 index 0000000000000..e3dbc6b2c2354 --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/constructoroptions.md @@ -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` + +#### 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) diff --git a/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/updateoptions.md b/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/updateoptions.md new file mode 100644 index 0000000000000..fbc0991635000 --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/updateoptions.md @@ -0,0 +1,58 @@ +[Alerts as data client API Interface](../alerts_client_api.md) / UpdateOptions + +# Interface: UpdateOptions + +## 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) diff --git a/x-pack/plugins/rule_registry/docs/alerts_client_typedoc.json b/x-pack/plugins/rule_registry/docs/alerts_client_typedoc.json new file mode 100644 index 0000000000000..5f117323eeb1c --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/alerts_client_typedoc.json @@ -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" +} + diff --git a/x-pack/plugins/rule_registry/kibana.json b/x-pack/plugins/rule_registry/kibana.json index 8c1e8d0f5e40e..f74bebf585edd 100644 --- a/x-pack/plugins/rule_registry/kibana.json +++ b/x-pack/plugins/rule_registry/kibana.json @@ -12,5 +12,6 @@ "spaces", "triggersActionsUi" ], + "optionalPlugins": ["security"], "server": true } diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.mock.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.mock.ts new file mode 100644 index 0000000000000..73c6b4dd40526 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.mock.ts @@ -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; +export type AlertsClientMock = jest.Mocked; + +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, +}; diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts new file mode 100644 index 0000000000000..553c5ce4472a6 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts @@ -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 = Omit & + { [K in Props]-?: NonNullable }; +type AlertType = NonNullableProps; + +const isValidAlert = (source?: ParsedTechnicalFields): source is AlertType => { + return source?.[RULE_ID] != null && source?.[OWNER] != null; +}; +export interface ConstructorOptions { + logger: Logger; + authorization: PublicMethodsOf; + auditLogger?: AuditLogger; + esClient: ElasticsearchClient; +} + +export interface UpdateOptions { + 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; + 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 + ) { + 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({ + // 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 { + 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({ + id, + status, + _version, + index, + }: UpdateOptions) { + 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({ + ...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 { + 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(); + 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; + } +} diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts new file mode 100644 index 0000000000000..9e1941f779722 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts @@ -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; + +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"`); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts new file mode 100644 index 0000000000000..43a3827b28972 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts @@ -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; + securityPluginSetup: SecurityPluginSetup | undefined; +} + +export class AlertsClientFactory { + private isInitialized = false; + private logger!: Logger; + private esClient!: ElasticsearchClient; + private getAlertingAuthorization!: ( + request: KibanaRequest + ) => PublicMethodsOf; + 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 { + const { securityPluginSetup, getAlertingAuthorization, logger } = this; + + return new AlertsClient({ + logger, + authorization: getAlertingAuthorization(request), + auditLogger: securityPluginSetup?.audit.asScoped(request), + esClient: this.esClient, + }); + } +} diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.test.ts new file mode 100644 index 0000000000000..9536a9a640a00 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.test.ts @@ -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]", + } + `); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.ts b/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.ts new file mode 100644 index 0000000000000..d07c23c7fbe9f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.ts @@ -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 = { + alert_get: ['access', 'accessing', 'accessed'], + alert_update: ['update', 'updating', 'updated'], + alert_find: ['access', 'accessing', 'accessed'], +}; + +const eventTypes: Record = { + 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, + }, + }; +} diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts new file mode 100644 index 0000000000000..897c17a82b982 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts @@ -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; + +const alertsClientParams: jest.Mocked = { + 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', + }); + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts new file mode 100644 index 0000000000000..6fc387fe54b3b --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts @@ -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; + +const alertsClientParams: jest.Mocked = { + 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', + }); + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/event_log/elasticsearch/index_writer.ts b/x-pack/plugins/rule_registry/server/event_log/elasticsearch/index_writer.ts index 7f83421ec80d8..6fd1c954d8c14 100644 --- a/x-pack/plugins/rule_registry/server/event_log/elasticsearch/index_writer.ts +++ b/x-pack/plugins/rule_registry/server/event_log/elasticsearch/index_writer.ts @@ -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); } diff --git a/x-pack/plugins/rule_registry/server/index.ts b/x-pack/plugins/rule_registry/server/index.ts index 9eefc19f34670..b6fd6b9a605c0 100644 --- a/x-pack/plugins/rule_registry/server/index.ts +++ b/x-pack/plugins/rule_registry/server/index.ts @@ -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'; diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index 043b07f9d67c1..ca98254037732 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -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; + 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(); this.logger = initContext.logger.get(); this.eventLogService = null; + this.ruleDataService = null; + this.alertsClientFactory = new AlertsClientFactory(); } public setup( - core: CoreSetup + core: CoreSetup, + 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(); + core.http.registerRouteHandlerContext( + '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 => { + 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; diff --git a/x-pack/plugins/rule_registry/server/routes/__mocks__/request_context.ts b/x-pack/plugins/rule_registry/server/routes/__mocks__/request_context.ts new file mode 100644 index 0000000000000..6d47882ca86c4 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/__mocks__/request_context.ts @@ -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 = 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, +}; diff --git a/x-pack/plugins/rule_registry/server/routes/__mocks__/request_responses.ts b/x-pack/plugins/rule_registry/server/routes/__mocks__/request_responses.ts new file mode 100644 index 0000000000000..228fcf491994f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/__mocks__/request_responses.ts @@ -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*', + }, + }); diff --git a/x-pack/plugins/rule_registry/server/routes/__mocks__/response_adapters.ts b/x-pack/plugins/rule_registry/server/routes/__mocks__/response_adapters.ts new file mode 100644 index 0000000000000..7952b33dcf9b1 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/__mocks__/response_adapters.ts @@ -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; +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((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, + }; +}; diff --git a/x-pack/plugins/rule_registry/server/routes/__mocks__/server.ts b/x-pack/plugins/rule_registry/server/routes/__mocks__/server.ts new file mode 100644 index 0000000000000..ade72435c57d9 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/__mocks__/server.ts @@ -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; + 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, +}; diff --git a/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.test.ts b/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.test.ts new file mode 100644 index 0000000000000..0de1e6c585a17 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.test.ts @@ -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; + 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', + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.ts b/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.ts new file mode 100644 index 0000000000000..9ddec56055a5a --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.ts @@ -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) => { + 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, + }, + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/rule_registry/server/routes/get_alert_index.ts b/x-pack/plugins/rule_registry/server/routes/get_alert_index.ts new file mode 100644 index 0000000000000..b8b181a493cec --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/get_alert_index.ts @@ -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) => { + 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, + }) + ), + }); + } + } + ); +}; diff --git a/x-pack/plugins/rule_registry/server/routes/index.ts b/x-pack/plugins/rule_registry/server/routes/index.ts new file mode 100644 index 0000000000000..6698cd7717268 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/index.ts @@ -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) { + getAlertByIdRoute(router); + updateAlertByIdRoute(router); + getAlertsIndexRoute(router); +} diff --git a/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.test.ts b/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.test.ts new file mode 100644 index 0000000000000..7ec699491ca83 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.test.ts @@ -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; + 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', + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.ts b/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.ts new file mode 100644 index 0000000000000..a77688a514e77 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.ts @@ -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) => { + 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, + }, + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/rule_registry/server/routes/utils/route_validation.ts b/x-pack/plugins/rule_registry/server/routes/utils/route_validation.ts new file mode 100644 index 0000000000000..8e74760d6d15f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/utils/route_validation.ts @@ -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 = + | { + 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 = >( + schema: T +): RouteValidationFunction => ( + inputValue: unknown, + validationResult: RouteValidationResultFactory +): RequestValidationResult => + pipe( + schema.decode(inputValue), + (decoded) => exactCheck(inputValue, decoded), + fold>( + (errors: rt.Errors) => validationResult.badRequest(formatErrors(errors).join()), + (validatedInput: A) => validationResult.ok(validatedInput) + ) + ); diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts index 3b90079ec5238..54e9a1b3c9a6f 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts @@ -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( @@ -37,9 +38,17 @@ export interface IRuleDataClient { createWriteTargetIfNeeded(options: { namespace?: string }): Promise; } +/** + * 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; isWriteEnabled: boolean; ready: () => Promise; alias: string; + feature: ValidFeatureId; } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts index 33ff5281147e1..d84f85dbc99b7 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts @@ -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; logger: Logger; isWriteEnabled: boolean; @@ -223,9 +224,10 @@ export class RuleDataPluginService { return [this.options.index, assetName].filter(Boolean).join('-'); } - getRuleDataClient(alias: string, initialize: () => Promise) { + getRuleDataClient(feature: ValidFeatureId, alias: string, initialize: () => Promise) { return new RuleDataClient({ alias, + feature, getClusterClient: () => this.getClusterClient(), isWriteEnabled: this.isWriteEnabled(), ready: initialize, diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts new file mode 100644 index 0000000000000..275d68621864f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts @@ -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; + +const createRuleDataPluginServiceMock = (_: RuleDataPluginServiceConstructorOptions) => { + const mocked: jest.Mocked = { + 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>; +} = { + create: createRuleDataPluginServiceMock, +}; diff --git a/x-pack/plugins/rule_registry/server/scripts/README.md b/x-pack/plugins/rule_registry/server/scripts/README.md new file mode 100644 index 0000000000000..2b3f01f3c4d6b --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/README.md @@ -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\"" +} +``` diff --git a/x-pack/plugins/rule_registry/server/scripts/get_alerts_index.sh b/x-pack/plugins/rule_registry/server/scripts/get_alerts_index.sh new file mode 100755 index 0000000000000..bfa74aa016f02 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/get_alerts_index.sh @@ -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 . diff --git a/x-pack/plugins/rule_registry/server/scripts/get_observability_alert.sh b/x-pack/plugins/rule_registry/server/scripts/get_observability_alert.sh new file mode 100755 index 0000000000000..6fbd0eb3dc816 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/get_observability_alert.sh @@ -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 . diff --git a/x-pack/plugins/rule_registry/server/scripts/get_security_alert.sh b/x-pack/plugins/rule_registry/server/scripts/get_security_alert.sh new file mode 100755 index 0000000000000..9bf051c1c6412 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/get_security_alert.sh @@ -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 . diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/README.md b/x-pack/plugins/rule_registry/server/scripts/hunter/README.md new file mode 100644 index 0000000000000..a0269d5b060a3 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/README.md @@ -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 | diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/delete_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/delete_detections_user.sh new file mode 100755 index 0000000000000..595f0a49282d8 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/delete_detections_user.sh @@ -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 diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/detections_role.json b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_role.json new file mode 100644 index 0000000000000..80f63f80b849c --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_role.json @@ -0,0 +1,19 @@ +{ + "elasticsearch": { + "cluster": [], + "indices": [] + }, + "kibana": [ + { + "feature": { + "ml": ["read"], + "siem": ["all"], + "actions": ["read"], + "ruleRegistry": ["all"], + "builtInAlerts": ["all"], + "alerting": ["all"] + }, + "spaces": ["*"] + } + ] +} diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/detections_user.json b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_user.json new file mode 100644 index 0000000000000..f9454cc0ad2fe --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["hunter"], + "full_name": "Hunter", + "email": "detections-reader@example.com" +} diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/get_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/get_detections_role.sh new file mode 100755 index 0000000000000..7ec850ce220bb --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/get_detections_role.sh @@ -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 . diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/index.ts b/x-pack/plugins/rule_registry/server/scripts/hunter/index.ts new file mode 100644 index 0000000000000..3411589de7721 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/index.ts @@ -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 }; diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_role.sh new file mode 100755 index 0000000000000..debffe0fcac4c --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_role.sh @@ -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} diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_user.sh new file mode 100755 index 0000000000000..ab2a053081394 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_user.sh @@ -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} diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/README.md b/x-pack/plugins/rule_registry/server/scripts/observer/README.md new file mode 100644 index 0000000000000..dc7e989ba4635 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/README.md @@ -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 | \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/delete_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/observer/delete_detections_user.sh new file mode 100755 index 0000000000000..017d8904a51e1 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/delete_detections_user.sh @@ -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 diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/detections_role.json b/x-pack/plugins/rule_registry/server/scripts/observer/detections_role.json new file mode 100644 index 0000000000000..dd3d3f96e3a33 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/detections_role.json @@ -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": ["*"] + } + ] +} diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/detections_user.json b/x-pack/plugins/rule_registry/server/scripts/observer/detections_user.json new file mode 100644 index 0000000000000..9f06e7dcc29f1 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/detections_user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["observer"], + "full_name": "Observer", + "email": "monitoring-observer@example.com" +} \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/get_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/observer/get_detections_role.sh new file mode 100755 index 0000000000000..7ec850ce220bb --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/get_detections_role.sh @@ -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 . diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/get_observability_alert.sh b/x-pack/plugins/rule_registry/server/scripts/observer/get_observability_alert.sh new file mode 100755 index 0000000000000..dd71e9dc6af43 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/get_observability_alert.sh @@ -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 . diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/get_security_solution_alert.sh b/x-pack/plugins/rule_registry/server/scripts/observer/get_security_solution_alert.sh new file mode 100755 index 0000000000000..b4348266c9634 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/get_security_solution_alert.sh @@ -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 . diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/index.ts b/x-pack/plugins/rule_registry/server/scripts/observer/index.ts new file mode 100644 index 0000000000000..5feebc1caeed1 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/index.ts @@ -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 }; diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_role.sh new file mode 100755 index 0000000000000..4dddb64befc6b --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_role.sh @@ -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} \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_user.sh new file mode 100755 index 0000000000000..8a897c0d28142 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_user.sh @@ -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} \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/scripts/update_observability_alert.sh b/x-pack/plugins/rule_registry/server/scripts/update_observability_alert.sh new file mode 100755 index 0000000000000..f61fcf2662aa3 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/update_observability_alert.sh @@ -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\"] +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 . diff --git a/x-pack/plugins/rule_registry/server/types.ts b/x-pack/plugins/rule_registry/server/types.ts index 959c05fd1334e..f8bd1940b10a8 100644 --- a/x-pack/plugins/rule_registry/server/types.ts +++ b/x-pack/plugins/rule_registry/server/types.ts @@ -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; }; + +/** + * @public + */ +export interface RacApiRequestHandlerContext { + getAlertsClient: () => Promise; +} + +/** + * @internal + */ +export interface RacRequestHandlerContext extends RequestHandlerContext { + rac: RacApiRequestHandlerContext; +} diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index 38ddbd3f1876b..a37ba9ef56636 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -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", diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts index 005af59892b8a..34045a2a905f8 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts @@ -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({ diff --git a/x-pack/plugins/rule_registry/server/utils/rbac.ts b/x-pack/plugins/rule_registry/server/utils/rbac.ts new file mode 100644 index 0000000000000..812dbb8408812 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/utils/rbac.ts @@ -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); diff --git a/x-pack/plugins/rule_registry/tsconfig.json b/x-pack/plugins/rule_registry/tsconfig.json index 5aefe9769da22..f6253e441da31 100644 --- a/x-pack/plugins/rule_registry/tsconfig.json +++ b/x-pack/plugins/rule_registry/tsconfig.json @@ -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" } ] diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 4389b22611748..a8ad6c919a04d 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -238,6 +238,7 @@ export class Plugin implements IPlugin initializeRuleDataTemplatesPromise ); @@ -338,7 +339,7 @@ export class Plugin implements IPlugin { - 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 diff --git a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts index e07681afe2203..4e3740a1ccb1c 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts @@ -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", ], diff --git a/x-pack/test/functional/es_archives/rule_registry/alerts/data.json b/x-pack/test/functional/es_archives/rule_registry/alerts/data.json new file mode 100644 index 0000000000000..a9837210c2e5a --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_registry/alerts/data.json @@ -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" + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_registry/alerts/mappings.json b/x-pack/test/functional/es_archives/rule_registry/alerts/mappings.json new file mode 100644 index 0000000000000..4cb178d979982 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_registry/alerts/mappings.json @@ -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 + } + } + } + } +} diff --git a/x-pack/test/rule_registry/common/config.ts b/x-pack/test/rule_registry/common/config.ts new file mode 100644 index 0000000000000..8d1b3807a245b --- /dev/null +++ b/x-pack/test/rule_registry/common/config.ts @@ -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}`, + ] + : []), + ], + }, + }; + }; +} diff --git a/x-pack/test/rule_registry/common/ftr_provider_context.d.ts b/x-pack/test/rule_registry/common/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..aa56557c09df8 --- /dev/null +++ b/x-pack/test/rule_registry/common/ftr_provider_context.d.ts @@ -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; diff --git a/x-pack/test/rule_registry/common/lib/authentication/index.ts b/x-pack/test/rule_registry/common/lib/authentication/index.ts new file mode 100644 index 0000000000000..f76159976a902 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/authentication/index.ts @@ -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); +}; diff --git a/x-pack/test/rule_registry/common/lib/authentication/roles.ts b/x-pack/test/rule_registry/common/lib/authentication/roles.ts new file mode 100644 index 0000000000000..e38378dcfc8f2 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/authentication/roles.ts @@ -0,0 +1,435 @@ +/* + * 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 { Role } from './types'; + +export const noKibanaPrivileges: Role = { + name: 'no_kibana_privileges', + privileges: { + elasticsearch: { + indices: [], + }, + }, +}; + +export const globalRead: Role = { + name: 'global_read', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + apm: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const securitySolutionOnlyAll: Role = { + name: 'sec_only_all_spaces_space1', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyAllSpace2: Role = { + name: 'sec_only_all_spaces_space2', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['all'], + }, + spaces: ['space2'], + }, + ], + }, +}; + +export const securitySolutionOnlyRead: Role = { + name: 'sec_only_read_spaces_space1', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyReadSpace2: Role = { + name: 'sec_only_read_spaces_space2', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + }, + spaces: ['space2'], + }, + ], + }, +}; + +export const observabilityOnlyAll: Role = { + name: 'obs_only_all_spaces_space1', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityOnlyAllSpace2: Role = { + name: 'obs_only_all_spaces_space2', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['all'], + }, + spaces: ['space2'], + }, + ], + }, +}; + +export const observabilityOnlyRead: Role = { + name: 'obs_only_read_spaces_space1', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['read'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityOnlyReadSpace2: Role = { + name: 'obs_only_read_spaces_space2', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['read'], + }, + spaces: ['space2'], + }, + ], + }, +}; + +/** + * These roles have access to all spaces. + */ +export const securitySolutionOnlyAllSpacesAll: Role = { + name: 'sec_only_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const securitySolutionOnlyReadSpacesAll: Role = { + name: 'sec_only_read_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const observabilityOnlyAllSpacesAll: Role = { + name: 'obs_only_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const observabilityOnlyReadSpacesAll: Role = { + name: 'obs_only_read_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const roles = [ + noKibanaPrivileges, + globalRead, + securitySolutionOnlyAll, + securitySolutionOnlyRead, + observabilityOnlyAll, + observabilityOnlyRead, + observabilityOnlyReadSpacesAll, +]; + +/** + * These roles are only to be used in the 'trial' tests + * since they rely on subfeature privileges which are a gold licencse feature + * maybe put these roles into a separate roles file like "trial_roles"? + */ +export const observabilityMinReadAlertsRead: Role = { + name: 'obs_only_alerts_read', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read', 'alerts_read'], + ruleRegistry: ['all'], + actions: ['read'], + builtInAlerts: ['all'], + alerting: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityMinReadAlertsReadSpacesAll: Role = { + name: 'obs_minimal_read_alerts_read_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read', 'alerts_read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const observabilityMinimalRead: Role = { + name: 'obs_minimal_read', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityMinimalReadSpacesAll: Role = { + name: 'obs_minimal_read_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +/** + * **************************************** + * These are used for testing update alerts privileges + * **************************************** + * **************************************** + * **************************************** + * **************************************** + * **************************************** + * **************************************** + * **************************************** + * **************************************** + */ + +export const observabilityMinReadAlertsAll: Role = { + name: 'obs_only_alerts_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read', 'alerts_all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityMinReadAlertsAllSpacesAll: Role = { + name: 'obs_minimal_read_alerts_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read', 'alerts_all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const observabilityMinimalAll: Role = { + name: 'obs_minimal_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityMinimalAllSpacesAll: Role = { + name: 'obs_minimal_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const allRoles = [ + noKibanaPrivileges, + globalRead, + securitySolutionOnlyAll, + securitySolutionOnlyRead, + observabilityOnlyAll, + observabilityOnlyRead, + securitySolutionOnlyAllSpacesAll, + securitySolutionOnlyReadSpacesAll, + observabilityOnlyAllSpacesAll, + observabilityOnlyReadSpacesAll, + observabilityMinReadAlertsRead, + observabilityMinReadAlertsReadSpacesAll, + observabilityMinimalRead, + observabilityMinimalReadSpacesAll, + observabilityMinReadAlertsAll, + observabilityMinReadAlertsAllSpacesAll, + observabilityMinimalAll, + observabilityMinimalAllSpacesAll, + securitySolutionOnlyAllSpace2, + securitySolutionOnlyReadSpace2, + observabilityOnlyAllSpace2, + observabilityOnlyReadSpace2, +]; diff --git a/x-pack/test/rule_registry/common/lib/authentication/spaces.ts b/x-pack/test/rule_registry/common/lib/authentication/spaces.ts new file mode 100644 index 0000000000000..556b1686601ff --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/authentication/spaces.ts @@ -0,0 +1,26 @@ +/* + * 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 { Space } from './types'; + +const space1: Space = { + id: 'space1', + name: 'Space 1', + disabledFeatures: [], +}; + +const space2: Space = { + id: 'space2', + name: 'Space 2', + disabledFeatures: [], +}; + +export const spaces: Space[] = [space1, space2]; + +export const getSpaceUrlPrefix = (spaceId?: string) => { + return spaceId && spaceId !== 'default' ? `/s/${spaceId}` : ``; +}; diff --git a/x-pack/test/rule_registry/common/lib/authentication/types.ts b/x-pack/test/rule_registry/common/lib/authentication/types.ts new file mode 100644 index 0000000000000..3bf3629441f93 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/authentication/types.ts @@ -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. + */ + +export interface Space { + id: string; + namespace?: string; + name: string; + disabledFeatures: string[]; +} + +export interface User { + username: string; + password: string; + description?: string; + roles: string[]; +} + +export interface UserInfo { + username: string; + full_name: string; + email: string; +} + +interface FeaturesPrivileges { + [featureId: string]: string[]; +} + +interface ElasticsearchIndices { + names: string[]; + privileges: string[]; +} + +export interface ElasticSearchPrivilege { + cluster?: string[]; + indices?: ElasticsearchIndices[]; +} + +export interface KibanaPrivilege { + spaces: string[]; + base?: string[]; + feature?: FeaturesPrivileges; +} + +export interface Role { + name: string; + privileges: { + elasticsearch?: ElasticSearchPrivilege; + kibana?: KibanaPrivilege[]; + }; +} diff --git a/x-pack/test/rule_registry/common/lib/authentication/users.ts b/x-pack/test/rule_registry/common/lib/authentication/users.ts new file mode 100644 index 0000000000000..e142b3d1f56a3 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/authentication/users.ts @@ -0,0 +1,301 @@ +/* + * 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 { + securitySolutionOnlyAll, + observabilityOnlyAll, + securitySolutionOnlyRead, + observabilityOnlyRead, + globalRead as globalReadRole, + noKibanaPrivileges as noKibanaPrivilegesRole, + securitySolutionOnlyAllSpacesAll, + securitySolutionOnlyReadSpacesAll, + observabilityOnlyAllSpacesAll, + observabilityOnlyReadSpacesAll, + // trial license roles + observabilityMinReadAlertsAll, + observabilityMinReadAlertsRead, + observabilityMinReadAlertsReadSpacesAll, + observabilityMinimalRead, + observabilityMinimalReadSpacesAll, + securitySolutionOnlyAllSpace2, + securitySolutionOnlyReadSpace2, + observabilityOnlyAllSpace2, + observabilityOnlyReadSpace2, + observabilityMinReadAlertsAllSpacesAll, +} from './roles'; +import { User } from './types'; + +export const superUser: User = { + username: 'superuser', + password: 'superuser', + roles: ['superuser'], +}; + +export const secOnly: User = { + username: 'sec_only_all_spaces_space1', + password: 'sec_only_all_spaces_space1', + roles: [securitySolutionOnlyAll.name], +}; + +export const secOnlySpace2: User = { + username: 'sec_only_all_spaces_space2', + password: 'sec_only_all_spaces_space2', + roles: [securitySolutionOnlyAllSpace2.name], +}; + +export const secOnlyRead: User = { + username: 'sec_only_read_spaces_space1', + password: 'sec_only_read_spaces_space1', + roles: [securitySolutionOnlyRead.name], +}; + +export const secOnlyReadSpace2: User = { + username: 'sec_only_read_spaces_space2', + password: 'sec_only_read_spaces_space2', + roles: [securitySolutionOnlyReadSpace2.name], +}; + +export const obsOnly: User = { + username: 'obs_only_all_spaces_space1', + password: 'obs_only_all_spaces_space1', + roles: [observabilityOnlyAll.name], +}; + +export const obsOnlySpace2: User = { + username: 'obs_only_all_spaces_space2', + password: 'obs_only_all_spaces_space2', + roles: [observabilityOnlyAllSpace2.name], +}; + +export const obsOnlyRead: User = { + username: 'obs_only_read_spaces_space1', + password: 'obs_only_read_spaces_space1', + roles: [observabilityOnlyRead.name], +}; + +export const obsOnlyReadSpace2: User = { + username: 'obs_only_read_spaces_space2', + password: 'obs_only_read_spaces_space2', + roles: [observabilityOnlyReadSpace2.name], +}; + +export const obsSec: User = { + username: 'sec_only_all_spaces_space1_and_obs_only_all_spaces_space1', + password: 'sec_only_all_spaces_space1_and_obs_only_all_spaces_space1', + roles: [securitySolutionOnlyAll.name, observabilityOnlyAll.name], +}; + +export const obsSecAllSpace2: User = { + username: 'sec_only_all_spaces_space2_and_obs_only_all_spaces_space2', + password: 'sec_only_all_spaces_space2_and_obs_only_all_spaces_space2', + roles: [securitySolutionOnlyAllSpace2.name, observabilityOnlyAllSpace2.name], +}; + +export const obsSecRead: User = { + username: 'sec_only_read_spaces_space1_and_obs_only_read_spaces_space1', + password: 'sec_only_read_spaces_space1_and_obs_only_read_spaces_space1', + roles: [securitySolutionOnlyRead.name, observabilityOnlyRead.name], +}; + +export const obsSecReadSpace2: User = { + username: 'sec_only_read_spaces_space2_and_obs_only_read_spaces_space2', + password: 'sec_only_read_spaces_space2_and_obs_only_read_spaces_space2', + roles: [securitySolutionOnlyReadSpace2.name, observabilityOnlyReadSpace2.name], +}; + +export const globalRead: User = { + username: 'global_read', + password: 'global_read', + roles: [globalReadRole.name], +}; + +export const noKibanaPrivileges: User = { + username: 'no_kibana_privileges', + password: 'no_kibana_privileges', + roles: [noKibanaPrivilegesRole.name], +}; + +export const obsOnlyReadSpacesAll: User = { + username: 'obs_only_read_all_spaces_all', + password: 'obs_only_read_all_spaces_all', + roles: [observabilityOnlyReadSpacesAll.name], +}; + +export const users = [ + superUser, + secOnly, + secOnlyRead, + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, +]; + +/** + * These users will have access to all spaces. + */ + +export const secOnlySpacesAll: User = { + username: 'sec_only_all_spaces_all', + password: 'sec_only_all_spaces_all', + roles: [securitySolutionOnlyAllSpacesAll.name], +}; + +export const secOnlyReadSpacesAll: User = { + username: 'sec_only_read_spaces_all', + password: 'sec_only_read_spaces_all', + roles: [securitySolutionOnlyReadSpacesAll.name], +}; + +export const obsOnlySpacesAll: User = { + username: 'obs_only_all_spaces_all', + password: 'obs_only_all_spaces_all', + roles: [observabilityOnlyAllSpacesAll.name], +}; + +export const obsSecSpacesAll: User = { + username: 'sec_only_all_spaces_all_and_obs_only_all_spaces_all', + password: 'sec_only_all_spaces_all_and_obs_only_all_spaces_all', + roles: [securitySolutionOnlyAllSpacesAll.name, observabilityOnlyAllSpacesAll.name], +}; + +export const obsSecReadSpacesAll: User = { + username: 'sec_only_read_all_spaces_all_and_obs_only_read_all_spaces_all', + password: 'sec_only_read_all_spaces_all_and_obs_only_read_all_spaces_all', + roles: [securitySolutionOnlyReadSpacesAll.name, observabilityOnlyReadSpacesAll.name], +}; + +/** + * These users are for the security_only tests because most of them have access to the default space instead of 'space1' + */ +export const usersDefaultSpace = [ + superUser, + secOnlySpacesAll, + secOnlyReadSpacesAll, + obsOnlySpacesAll, + obsOnlyReadSpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + globalRead, + noKibanaPrivileges, +]; + +/** + * Trial users with trial roles + */ + +// apm: ['minimal_read', 'alerts_read'] +// spaces: ['space1'] +export const obsMinReadAlertsRead: User = { + username: 'obs_minimal_read_alerts_read_single_space', + password: 'obs_minimal_read_alerts_read_single_space', + roles: [observabilityMinReadAlertsRead.name], +}; + +// apm: ['minimal_read', 'alerts_read'] +// spaces: ['*'] +export const obsMinReadAlertsReadSpacesAll: User = { + username: 'obs_minimal_read_alerts_read_all_spaces', + password: 'obs_minimal_read_alerts_read_all_spaces', + roles: [observabilityMinReadAlertsReadSpacesAll.name], +}; + +// apm: ['minimal_read'] +// spaces: ['space1'] +export const obsMinRead: User = { + username: 'obs_minimal_read_single_space', + password: 'obs_minimal_read_single_space', + roles: [observabilityMinimalRead.name], +}; + +// apm: ['minimal_read'] +// spaces: ['*'] +export const obsMinReadSpacesAll: User = { + username: 'obs_minimal_read_all_space', + password: 'obs_minimal_read_all_space', + roles: [observabilityMinimalReadSpacesAll.name], +}; + +// FOR UPDATES +// apm: ['minimal_read', 'alerts_all'] +// spaces: ['space1'] +export const obsMinReadAlertsAll: User = { + username: 'obs_minimal_read_alerts_all_single_space', + password: 'obs_minimal_read_alerts_all_single_space', + roles: [observabilityMinReadAlertsAll.name], +}; + +// apm: ['minimal_read', 'alerts_all'] +// spaces: ['*'] +export const obsMinReadAlertsAllSpacesAll: User = { + username: 'obs_minimal_read_alerts_all_all_spaces', + password: 'obs_minimal_read_alerts_all_all_spaces', + roles: [observabilityMinReadAlertsAllSpacesAll.name], +}; + +// apm: ['minimal_all'] +// spaces: ['space1'] +export const obsMinAll: User = { + username: 'obs_minimal_all_single_space', + password: 'obs_minimal_all_single_space', + roles: [observabilityMinimalRead.name], +}; + +// apm: ['minimal_all'] +// spaces: ['*'] +export const obsMinAllSpacesAll: User = { + username: 'obs_minimal_all_all_space', + password: 'obs_minimal_read_all_space', + roles: [observabilityMinimalReadSpacesAll.name], +}; + +export const trialUsers = [ + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, + obsMinRead, + obsMinReadSpacesAll, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, +]; + +export const allUsers = [ + superUser, + secOnly, + secOnlyRead, + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, + obsOnlySpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, + obsMinRead, + obsMinReadSpacesAll, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, + secOnlySpace2, + secOnlyReadSpace2, + obsOnlySpace2, + obsOnlyReadSpace2, + obsSecAllSpace2, + obsSecReadSpace2, +]; diff --git a/x-pack/test/rule_registry/common/services.ts b/x-pack/test/rule_registry/common/services.ts new file mode 100644 index 0000000000000..7e415338c405f --- /dev/null +++ b/x-pack/test/rule_registry/common/services.ts @@ -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 { services } from '../../api_integration/services'; diff --git a/x-pack/test/rule_registry/security_and_spaces/config_basic.ts b/x-pack/test/rule_registry/security_and_spaces/config_basic.ts new file mode 100644 index 0000000000000..98b7b1abe98e7 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/config_basic.ts @@ -0,0 +1,15 @@ +/* + * 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 { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('security_and_spaces', { + license: 'basic', + ssl: true, + testFiles: [require.resolve('./tests/basic')], +}); diff --git a/x-pack/test/rule_registry/security_and_spaces/config_trial.ts b/x-pack/test/rule_registry/security_and_spaces/config_trial.ts new file mode 100644 index 0000000000000..b5328fd83c2cb --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/config_trial.ts @@ -0,0 +1,15 @@ +/* + * 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 { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('security_and_spaces', { + license: 'trial', + ssl: true, + testFiles: [require.resolve('./tests/trial')], +}); diff --git a/x-pack/test/rule_registry/security_and_spaces/roles_users_utils/index.ts b/x-pack/test/rule_registry/security_and_spaces/roles_users_utils/index.ts new file mode 100644 index 0000000000000..b320446cbe05f --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/roles_users_utils/index.ts @@ -0,0 +1,124 @@ +/* + * 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 { assertUnreachable } from '../../../../plugins/security_solution/common/utility_types'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + t1AnalystUser, + t2AnalystUser, + hunterUser, + ruleAuthorUser, + socManagerUser, + platformEngineerUser, + detectionsAdminUser, + readerUser, + t1AnalystRole, + t2AnalystRole, + hunterRole, + ruleAuthorRole, + socManagerRole, + platformEngineerRole, + detectionsAdminRole, + readerRole, +} from '../../../../plugins/security_solution/server/lib/detection_engine/scripts/roles_users'; + +import { ROLES } from '../../../../plugins/security_solution/common/test'; + +export const createUserAndRole = async ( + getService: FtrProviderContext['getService'], + role: ROLES +): Promise => { + switch (role) { + case ROLES.detections_admin: + return postRoleAndUser( + ROLES.detections_admin, + detectionsAdminRole, + detectionsAdminUser, + getService + ); + case ROLES.t1_analyst: + return postRoleAndUser(ROLES.t1_analyst, t1AnalystRole, t1AnalystUser, getService); + case ROLES.t2_analyst: + return postRoleAndUser(ROLES.t2_analyst, t2AnalystRole, t2AnalystUser, getService); + case ROLES.hunter: + return postRoleAndUser(ROLES.hunter, hunterRole, hunterUser, getService); + case ROLES.rule_author: + return postRoleAndUser(ROLES.rule_author, ruleAuthorRole, ruleAuthorUser, getService); + case ROLES.soc_manager: + return postRoleAndUser(ROLES.soc_manager, socManagerRole, socManagerUser, getService); + case ROLES.platform_engineer: + return postRoleAndUser( + ROLES.platform_engineer, + platformEngineerRole, + platformEngineerUser, + getService + ); + case ROLES.reader: + return postRoleAndUser(ROLES.reader, readerRole, readerUser, getService); + default: + return assertUnreachable(role); + } +}; + +/** + * Given a roleName and security service this will delete the roleName + * and user + * @param roleName The user and role to delete with the same name + * @param securityService The security service + */ +export const deleteUserAndRole = async ( + getService: FtrProviderContext['getService'], + roleName: ROLES +): Promise => { + const securityService = getService('security'); + await securityService.user.delete(roleName); + await securityService.role.delete(roleName); +}; + +interface UserInterface { + password: string; + roles: string[]; + full_name: string; + email: string; +} + +interface RoleInterface { + elasticsearch: { + cluster: string[]; + indices: Array<{ + names: string[]; + privileges: string[]; + }>; + }; + kibana: Array<{ + feature: { + ml: string[]; + siem: string[]; + actions: string[]; + builtInAlerts: string[]; + }; + spaces: string[]; + }>; +} + +export const postRoleAndUser = async ( + roleName: string, + role: RoleInterface, + user: UserInterface, + getService: FtrProviderContext['getService'] +): Promise => { + const securityService = getService('security'); + await securityService.role.create(roleName, { + kibana: role.kibana, + elasticsearch: role.elasticsearch, + }); + await securityService.user.create(roleName, { + password: 'changeme', + full_name: user.full_name, + roles: user.roles, + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/get_alert_by_id.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/get_alert_by_id.ts new file mode 100644 index 0000000000000..cf3cc88f2cfc0 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/get_alert_by_id.ts @@ -0,0 +1,210 @@ +/* + * 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 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('Alerts - GET - RBAC - spaces', () => { + before(async () => { + await getSecuritySolutionIndexName(superUser); + await getAPMIndexName(superUser); + + 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 be able to access alert ${alertId} in ${space}/${index}`, async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(space)}${TEST_URL}?id=${alertId}&index=${index}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .expect(200); + }); + + it(`${username} should fail to access a non-existent alert in ${space}/${index}`, async () => { + const fakeAlertId = 'some-alert-id-that-doesnt-exist'; + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(space)}${TEST_URL}?id=${fakeAlertId}&index=${index}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .expect(404); + }); + + it(`${username} should return a 404 when trying to 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') + .expect(404); + }); + }); + + unauthorizedUsers.forEach(({ username, password }) => { + it(`${username} should NOT be able to access alert ${alertId} in ${space}/${index}`, async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(space)}${TEST_URL}?id=${alertId}&index=${index}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .expect(403); + }); + }); + } + + describe('Security Solution', () => { + const authorizedInAllSpaces = [superUser, globalRead, secOnlySpacesAll, obsSecSpacesAll]; + const authorizedOnlyInSpace1 = [secOnly, secOnlyRead, obsSec, obsSecRead]; + const authorizedOnlyInSpace2 = [ + secOnlySpace2, + secOnlyReadSpace2, + obsSecAllSpace2, + obsSecReadSpace2, + ]; + const unauthorized = [ + // these users are not authorized to access alerts for the Security Solution in any space + 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, globalRead, obsOnlySpacesAll, obsSecSpacesAll]; + const authorizedOnlyInSpace1 = [obsOnly, obsOnlyRead, obsSec, obsSecRead]; + const authorizedOnlyInSpace2 = [ + obsOnlySpace2, + obsOnlyReadSpace2, + obsSecAllSpace2, + obsSecReadSpace2, + ]; + const unauthorized = [ + // these users are not authorized to access alerts for APM in any space + 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], + }); + }); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/index.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/index.ts new file mode 100644 index 0000000000000..baea62c157218 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/index.ts @@ -0,0 +1,29 @@ +/* + * 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 } from '../../../common/ftr_provider_context'; +import { createSpacesAndUsers, deleteSpacesAndUsers } from '../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('rules security and spaces enabled: basic', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + await createSpacesAndUsers(getService); + }); + + after(async () => { + await deleteSpacesAndUsers(getService); + }); + + // Basic + loadTestFile(require.resolve('./get_alert_by_id')); + loadTestFile(require.resolve('./update_alert')); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/update_alert.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/update_alert.ts new file mode 100644 index 0000000000000..4fb087e813768 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/update_alert.ts @@ -0,0 +1,251 @@ +/* + * 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 - 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 be able to update alert ${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 + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(space)}${TEST_URL}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .send({ + ids: [alertId], + status: 'closed', + index, + _version: ALERT_VERSION, + }) + .expect(200); + }); + + it(`${username} should fail to update alert ${alertId} in ${space}/${index} with an incorrect version`, async () => { + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(space)}${TEST_URL}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .send({ + ids: [alertId], + status: 'closed', + index, + _version: Buffer.from(JSON.stringify([999, 999]), 'utf8').toString('base64'), + }) + .expect(409); + }); + + it(`${username} should fail to update a non-existent alert in ${space}/${index}`, async () => { + const fakeAlertId = 'some-alert-id-that-doesnt-exist'; + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(space)}${TEST_URL}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .send({ + ids: [fakeAlertId], + status: 'closed', + index, + _version: ALERT_VERSION, + }) + .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 + .post(`${getSpaceUrlPrefix(space)}${TEST_URL}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .send({ + ids: [alertId], + status: 'closed', + index, + _version: ALERT_VERSION, + }) + .expect(403); + }); + }); + } + + 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], + }); + }); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/trial/get_alerts.ts b/x-pack/test/rule_registry/security_and_spaces/tests/trial/get_alerts.ts new file mode 100644 index 0000000000000..a38f6cf3263b1 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/trial/get_alerts.ts @@ -0,0 +1,127 @@ +/* + * 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, + obsMinReadSpacesAll, + obsMinRead, + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, +} from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const TEST_URL = '/internal/rac/alerts'; + const ALERTS_INDEX_URL = `${TEST_URL}/index`; + const SPACE1 = 'space1'; + const SPACE2 = 'space2'; + + 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 === '.alerts-observability-apm' + ); + expect(observabilityIndex).to.eql('.alerts-observability-apm'); + return observabilityIndex; + }; + + describe('rbac with subfeatures', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + describe('Users:', () => { + // user with minimal_read and alerts_read privileges should be able to access apm alert + it(`${obsMinReadAlertsRead.username} should be able to access the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinReadAlertsRead.username, obsMinReadAlertsRead.password) + .set('kbn-xsrf', 'true') + .expect(200); + }); + it(`${obsMinReadAlertsReadSpacesAll.username} should be able to access the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinReadAlertsReadSpacesAll.username, obsMinReadAlertsReadSpacesAll.password) + .set('kbn-xsrf', 'true') + .expect(200); + }); + + it(`${obsMinRead.username} should NOT be able to access the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinRead.username, obsMinRead.password) + .set('kbn-xsrf', 'true') + .expect(403); + }); + + it(`${obsMinReadSpacesAll.username} should NOT be able to access the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinReadSpacesAll.username, obsMinReadSpacesAll.password) + .set('kbn-xsrf', 'true') + .expect(403); + }); + }); + + describe('Space:', () => { + it(`${obsMinReadAlertsRead.username} should NOT be able to access the APM alert in ${SPACE2}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE2)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinReadAlertsRead.username, obsMinReadAlertsRead.password) + .set('kbn-xsrf', 'true') + .expect(403); + }); + + it(`${obsMinReadAlertsReadSpacesAll.username} should be able to access the APM alert in ${SPACE2}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE2)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinReadAlertsReadSpacesAll.username, obsMinReadAlertsReadSpacesAll.password) + .set('kbn-xsrf', 'true') + .expect(200); + }); + + describe('extra params', () => { + it('should NOT allow to pass a filter query parameter', async () => { + await supertest + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?sortOrder=asc&namespaces[0]=*`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + + it('should NOT allow to pass a non supported query parameter', async () => { + await supertest + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?notExists=something`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + }); + }); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/trial/index.ts b/x-pack/test/rule_registry/security_and_spaces/tests/trial/index.ts new file mode 100644 index 0000000000000..5e89f99200f2d --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/trial/index.ts @@ -0,0 +1,104 @@ +/* + * 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 } from '../../../common/ftr_provider_context'; +import { + createSpaces, + createUsersAndRoles, + deleteSpaces, + deleteUsersAndRoles, +} from '../../../common/lib/authentication'; + +import { + observabilityMinReadAlertsRead, + observabilityMinReadAlertsReadSpacesAll, + observabilityMinimalRead, + observabilityMinimalReadSpacesAll, + observabilityMinReadAlertsAll, + observabilityMinReadAlertsAllSpacesAll, + observabilityMinimalAll, + observabilityMinimalAllSpacesAll, +} from '../../../common/lib/authentication/roles'; +import { + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, + obsMinRead, + obsMinReadSpacesAll, + superUser, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, +} from '../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('rules security and spaces enabled: trial', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + await createSpaces(getService); + await createUsersAndRoles( + getService, + [ + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, + obsMinRead, + obsMinReadSpacesAll, + superUser, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, + ], + [ + observabilityMinReadAlertsRead, + observabilityMinReadAlertsReadSpacesAll, + observabilityMinimalRead, + observabilityMinimalReadSpacesAll, + observabilityMinReadAlertsAll, + observabilityMinReadAlertsAllSpacesAll, + observabilityMinimalAll, + observabilityMinimalAllSpacesAll, + ] + ); + }); + + after(async () => { + await deleteSpaces(getService); + await deleteUsersAndRoles( + getService, + [ + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, + obsMinRead, + obsMinReadSpacesAll, + superUser, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, + ], + [ + observabilityMinReadAlertsRead, + observabilityMinReadAlertsReadSpacesAll, + observabilityMinimalRead, + observabilityMinimalReadSpacesAll, + observabilityMinReadAlertsAll, + observabilityMinReadAlertsAllSpacesAll, + observabilityMinimalAll, + observabilityMinimalAllSpacesAll, + ] + ); + }); + + // Trial + loadTestFile(require.resolve('./get_alerts')); + loadTestFile(require.resolve('./update_alert')); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/trial/update_alert.ts b/x-pack/test/rule_registry/security_and_spaces/tests/trial/update_alert.ts new file mode 100644 index 0000000000000..c126c434bd4cf --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/trial/update_alert.ts @@ -0,0 +1,189 @@ +/* + * 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, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, +} from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const TEST_URL = '/internal/rac/alerts'; + const ALERTS_INDEX_URL = `${TEST_URL}/index`; + const SPACE1 = 'space1'; + + 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 === '.alerts-observability-apm' + ); + expect(observabilityIndex).to.eql('.alerts-observability-apm'); + return observabilityIndex; + }; + + describe('rbac', () => { + describe('Users update:', () => { + beforeEach(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + afterEach(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + it(`${superUser.username} should be able to update the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(superUser.username, superUser.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(200); + }); + + it(`${superUser.username} should receive a 409 if trying to update an old alert document version`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(superUser.username, superUser.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(200); + + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(superUser.username, superUser.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([999, 999]), 'utf8').toString('base64'), + }) + .expect(409); + }); + + it(`${obsMinReadAlertsAllSpacesAll.username} should be able to update the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + const res = await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinReadAlertsAllSpacesAll.username, obsMinReadAlertsAllSpacesAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(200); + expect(res.body).to.eql({ + success: true, + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + result: 'updated', + _shards: { total: 2, successful: 1, failed: 0 }, + _version: 'WzEsMV0=', + _seq_no: 1, + _primary_term: 1, + }); + }); + it(`${obsMinReadAlertsAllSpacesAll.username} should receive a 409 if trying to update an old alert document version`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinReadAlertsAllSpacesAll.username, obsMinReadAlertsAllSpacesAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(200); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinReadAlertsAllSpacesAll.username, obsMinReadAlertsAllSpacesAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([999, 999]), 'utf8').toString('base64'), + }) + .expect(409); + }); + + it(`${obsMinReadAlertsAll.username} should be able to update the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinReadAlertsAll.username, obsMinReadAlertsAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(200); + }); + it(`${obsMinAll.username} should NOT be able to update the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinAll.username, obsMinAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(403); + }); + + it(`${obsMinAllSpacesAll.username} should NOT be able to update the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinAllSpacesAll.username, obsMinAllSpacesAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(403); + }); + }); + }); +}; diff --git a/x-pack/test/rule_registry/spaces_only/config_trial.ts b/x-pack/test/rule_registry/spaces_only/config_trial.ts new file mode 100644 index 0000000000000..e788a16d0272f --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/config_trial.ts @@ -0,0 +1,16 @@ +/* + * 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 { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('spaces_only', { + license: 'trial', + disabledPlugins: ['security'], + ssl: false, + testFiles: [require.resolve('./tests/trial')], +}); diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/get_alert_by_id.ts b/x-pack/test/rule_registry/spaces_only/tests/trial/get_alert_by_id.ts new file mode 100644 index 0000000000000..df188718bff19 --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/get_alert_by_id.ts @@ -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 expect from '@kbn/expect'; + +import { superUser } from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const 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_INDEX = '.alerts-security.alerts'; + + const getAPMIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .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}`) + .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('Alerts - GET - RBAC - spaces', () => { + before(async () => { + await getSecuritySolutionIndexName(superUser); + await getAPMIndexName(superUser); + + 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'); + }); + + it('should return a 404 when superuser accesses not-existent alert', async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix()}${TEST_URL}?id=myfakeid&index=${APM_ALERT_INDEX}`) + .set('kbn-xsrf', 'true') + .expect(404); + }); + + it('should return a 404 when superuser accesses not-existent alerts as data index', async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix()}${TEST_URL}?id=${APM_ALERT_ID}&index=myfakeindex`) + .set('kbn-xsrf', 'true') + .expect(404); + }); + + it(`${superUser.username} should be able to access alert ${APM_ALERT_ID} in ${SPACE2}/${APM_ALERT_INDEX}`, async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE2)}${TEST_URL}?id=${APM_ALERT_ID}&index=${APM_ALERT_INDEX}`) + .set('kbn-xsrf', 'true') + .expect(200); + }); + }); +}; diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/index.ts b/x-pack/test/rule_registry/spaces_only/tests/trial/index.ts new file mode 100644 index 0000000000000..6deba4c68d0e2 --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/index.ts @@ -0,0 +1,29 @@ +/* + * 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 } from '../../../common/ftr_provider_context'; +import { createSpaces, deleteSpaces } from '../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('rule registry spaces only: trial', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + await createSpaces(getService); + }); + + after(async () => { + await deleteSpaces(getService); + }); + + // Basic + loadTestFile(require.resolve('./get_alert_by_id')); + loadTestFile(require.resolve('./update_alert')); + }); +}; diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/update_alert.ts b/x-pack/test/rule_registry/spaces_only/tests/trial/update_alert.ts new file mode 100644 index 0000000000000..f5179b253b701 --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/update_alert.ts @@ -0,0 +1,108 @@ +/* + * 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 } from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const 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_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}`) + .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}`) + .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 - 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'); + }); + + it('should return a 404 when superuser accesses not-existent alert', async () => { + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix()}${TEST_URL}`) + .set('kbn-xsrf', 'true') + .send({ + ids: ['this id does not exist'], + status: 'closed', + index: APM_ALERT_INDEX, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(404); + }); + + it('should return a 404 when superuser accesses not-existent alerts as data index', async () => { + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix()}${TEST_URL}`) + .set('kbn-xsrf', 'true') + .send({ + ids: [APM_ALERT_ID], + status: 'closed', + index: 'this index does not exist', + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(404); + }); + + it(`${superUser.username} should be able to update alert ${APM_ALERT_ID} in ${SPACE2}/${APM_ALERT_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 + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE2)}${TEST_URL}`) + .set('kbn-xsrf', 'true') + .send({ + ids: [APM_ALERT_ID], + status: 'closed', + index: APM_ALERT_INDEX, + _version: ALERT_VERSION, + }) + .expect(200); + }); + }); +};