diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index 12df93d54b296..19a429a0c1a7a 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', @@ -43,7 +45,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, }, [AlertType.TransactionDuration]: { name: i18n.translate('xpack.apm.transactionDurationAlert.name', { @@ -52,7 +54,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, }, [AlertType.TransactionDurationAnomaly]: { name: i18n.translate('xpack.apm.transactionDurationAnomalyAlert.name', { @@ -61,7 +63,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, }, [AlertType.TransactionErrorRate]: { name: i18n.translate('xpack.apm.transactionErrorRateAlert.name', { @@ -70,7 +72,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, }, }; 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 50788c28999b5..e227dc863f9e1 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 { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; interface Props { @@ -38,7 +41,7 @@ export function AlertingFlyout(props: Props) { () => alertType && 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 a340a940f4a3b..ae5d3db70f7c1 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; 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,7 +15,7 @@ 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', }), diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 9ab56c1a303ea..f603249b8d1e2 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -120,6 +120,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 15ec5d0ef0bd0..b4624009c5f6a 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 @@ -22,6 +22,7 @@ import { apmActionVariables } from './action_variables'; import { alertingEsClient } from './alerting_es_client'; import { RegisterRuleDependencies } from './register_apm_alerts'; import { createAPMLifecycleRuleType } from './create_apm_lifecycle_rule_type'; +import { APM_SERVER_FEATURE_ID } from '../../../common/alert_types'; const paramsSchema = schema.object({ windowSize: schema.number(), @@ -55,7 +56,7 @@ export function registerErrorCountAlertType({ apmActionVariables.interval, ], }, - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); 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 4918a6cc892b7..9ac310d1fa557 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 @@ -9,7 +9,11 @@ import { schema } from '@kbn/config-schema'; import { take } from 'rxjs/operators'; import { QueryContainer } from '@elastic/elasticsearch/api/types'; import { parseEnvironmentUrlParam } from '../../../common/environment_filter_values'; -import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { + AlertType, + ALERT_TYPES_CONFIG, + APM_SERVER_FEATURE_ID, +} from '../../../common/alert_types'; import { PROCESSOR_EVENT, SERVICE_ENVIRONMENT, @@ -65,7 +69,7 @@ export function registerTransactionDurationAlertType({ apmActionVariables.interval, ], }, - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index 67ff7cdb8e4e0..fe7758cce5479 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -24,6 +24,7 @@ import { AlertType, ALERT_TYPES_CONFIG, ANOMALY_ALERT_SEVERITY_TYPES, + APM_SERVER_FEATURE_ID, } from '../../../common/alert_types'; import { getMLJobs } from '../service_map/get_service_anomalies'; import { apmActionVariables } from './action_variables'; @@ -70,7 +71,7 @@ export function registerTransactionDurationAnomalyAlertType({ apmActionVariables.triggerValue, ], }, - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', executor: async ({ services, params }) => { if (!ml) { 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 bead17e308f06..8fdd043072552 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 @@ -7,7 +7,11 @@ import { schema } from '@kbn/config-schema'; import { take } from 'rxjs/operators'; -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, @@ -59,7 +63,7 @@ export function registerTransactionErrorRateAlertType({ apmActionVariables.interval, ], }, - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', executor: async ({ services, params: alertParams }) => { const config = await config$.pipe(take(1)).toPromise(); diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index 7febba197647d..9107336005298 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -104,6 +104,34 @@ export interface FeatureKibanaPrivileges { */ read?: readonly string[]; }; + + /** + * Solutions should specify owners of alerts here which will provide the solution read / write access to those alerts. + */ + rac?: { + /** + * List of owners of alerts which users should have full read/write access to when granted this privilege. + * @example + * ```ts + * { + * all: ['securitySolution'] + * } + * ``` + */ + all?: readonly string[]; + + /** + * List of owners of alerts which users should have read-only access to when granted this privilege. + * @example + * ```ts + * { + * read: ['securitySolution', 'observability'] + * } + * ``` + */ + read?: readonly string[]; + }; + /** * If your feature requires access to specific saved objects, then specify your access needs here. */ diff --git a/x-pack/plugins/features/common/kibana_feature.ts b/x-pack/plugins/features/common/kibana_feature.ts index 7c9f930c106b0..f7438b079fe11 100644 --- a/x-pack/plugins/features/common/kibana_feature.ts +++ b/x-pack/plugins/features/common/kibana_feature.ts @@ -98,6 +98,11 @@ export interface KibanaFeatureConfig { */ alerting?: readonly string[]; + /** + * If your feature grants access to specific alerts, you can specify them here to control visibility based on the current space. + */ + rac?: readonly string[]; + /** * Feature privilege definition. * @@ -191,6 +196,10 @@ export class KibanaFeature { return this.config.reserved; } + public get rac() { + return this.config.rac; + } + public toRaw() { return { ...this.config } as KibanaFeatureConfig; } diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 204c5bdfe2469..29680aa5f2a96 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -33,6 +33,7 @@ const managementSchema = Joi.object().pattern( ); const catalogueSchema = Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)); const alertingSchema = Joi.array().items(Joi.string()); +const racSchema = Joi.array().items(Joi.string()); const appCategorySchema = Joi.object({ id: Joi.string().required(), @@ -56,6 +57,10 @@ const kibanaPrivilegeSchema = Joi.object({ all: Joi.array().items(Joi.string()).required(), read: Joi.array().items(Joi.string()).required(), }).required(), + rac: Joi.object({ + all: racSchema, + read: racSchema, + }), ui: Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)).required(), }); @@ -113,6 +118,7 @@ const kibanaFeatureSchema = Joi.object({ management: managementSchema, catalogue: catalogueSchema, alerting: alertingSchema, + rac: racSchema, privileges: Joi.object({ all: kibanaPrivilegeSchema, read: kibanaPrivilegeSchema, @@ -161,7 +167,7 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { throw validateResult.error; } // the following validation can't be enforced by the Joi schema, since it'd require us looking "up" the object graph for the list of valid value, which they explicitly forbid. - const { app = [], management = {}, catalogue = [], alerting = [] } = feature; + const { app = [], management = {}, catalogue = [], alerting = [], rac = [] } = feature; const unseenApps = new Set(app); @@ -176,6 +182,8 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { const unseenAlertTypes = new Set(alerting); + const unseenRacTypes = new Set(rac); + function validateAppEntry(privilegeId: string, entry: readonly string[] = []) { entry.forEach((privilegeApp) => unseenApps.delete(privilegeApp)); @@ -219,6 +227,23 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { } } + function validateRacEntry(privilegeId: string, entry: FeatureKibanaPrivileges['rac']) { + const all = entry?.all ?? []; + const read = entry?.read ?? []; + + all.forEach((privilegeOwner) => unseenRacTypes.delete(privilegeOwner)); + read.forEach((privilegeOwner) => unseenRacTypes.delete(privilegeOwner)); + + const unknownRacEntries = difference([...all, ...read], rac); + if (unknownRacEntries.length > 0) { + throw new Error( + `Feature privilege ${ + feature.id + }.${privilegeId} has unknown rac entries: ${unknownRacEntries.join(', ')}` + ); + } + } + function validateManagementEntry( privilegeId: string, managementEntry: Record = {} @@ -280,6 +305,8 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { validateManagementEntry(privilegeId, privilegeDefinition.management); validateAlertingEntry(privilegeId, privilegeDefinition.alerting); + + // validateRacEntry(privilegeId, privilegeDefinition.rac); }); const subFeatureEntries = feature.subFeatures ?? []; @@ -290,6 +317,7 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { validateCatalogueEntry(subFeaturePrivilege.id, subFeaturePrivilege.catalogue); validateManagementEntry(subFeaturePrivilege.id, subFeaturePrivilege.management); validateAlertingEntry(subFeaturePrivilege.id, subFeaturePrivilege.alerting); + // validateRacEntry(subFeaturePrivilege.id, subFeaturePrivilege.rac); }); }); }); diff --git a/x-pack/plugins/monitoring/kibana.json b/x-pack/plugins/monitoring/kibana.json index 7d82006c6b999..16948a848dfd9 100644 --- a/x-pack/plugins/monitoring/kibana.json +++ b/x-pack/plugins/monitoring/kibana.json @@ -6,6 +6,7 @@ "requiredPlugins": [ "licensing", "features", + "ruleRegistry", "data", "navigation", "kibanaLegacy", diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index 56c654963d340..3c27e5f1460e0 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -176,6 +176,19 @@ export class MonitoringPlugin logger: this.log, }); initInfraSource(config, plugins.infra); + router.get({ path: '/monitoring-myfakepath', validate: false }, async (context, req, res) => { + try { + const racClient = await context.ruleRegistry?.getRacClient(); + const thing = await racClient?.find({ owner: 'observability' }); + console.error('THE THING!!!', JSON.stringify(thing.body, null, 2)); + return res.ok({ body: { success: true, alerts: thing.body.hits.hits } }); + } catch (err) { + console.error('monitoring route threw an error'); + console.error(err); + return res.unauthorized({ body: { message: err.message } }); + // return res.customError({ statusCode: err.statusCode, body: { message: err.message } }); + } + }); } return { @@ -244,8 +257,30 @@ export class MonitoringPlugin }), category: DEFAULT_APP_CATEGORIES.management, app: ['monitoring', 'kibana'], + rac: ['observability'], catalogue: ['monitoring'], - privileges: null, + privileges: { + all: { + rac: { + all: ['observability'], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['show', 'save', 'alerting:show', 'alerting:save'], + }, + read: { + rac: { + all: ['observability'], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['show', 'save', 'alerting:show', 'alerting:save'], + }, + }, alerting: ALERTS, reserved: { description: i18n.translate('xpack.monitoring.feature.reserved.description', { diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index 3dcf6862b7232..40bc837c85065 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -21,6 +21,7 @@ import type { ActionsApiRequestHandlerContext, } from '../../actions/server'; import type { AlertingApiRequestHandlerContext } from '../../alerting/server'; +import { RacApiRequestHandlerContext } from '../../rule_registry/server'; import { PluginStartContract as AlertingPluginStartContract, PluginSetupContract as AlertingPluginSetupContract, @@ -58,6 +59,7 @@ export interface RequestHandlerContextMonitoringPlugin extends RequestHandlerCon actions?: ActionsApiRequestHandlerContext; alerting?: AlertingApiRequestHandlerContext; infra: InfraRequestHandlerContext; + ruleRegistry?: RacApiRequestHandlerContext; } export interface PluginsStart { diff --git a/x-pack/plugins/rule_registry/README.md b/x-pack/plugins/rule_registry/README.md index 2c8f534a63d6b..f560ee8c79fcf 100644 --- a/x-pack/plugins/rule_registry/README.md +++ b/x-pack/plugins/rule_registry/README.md @@ -1,5 +1,21 @@ # Rule Registry +# RAC + +The RAC plugin provides a common place to register rules with alerting. You can: + +- Register types of rules +- Perform CRUD actions on rules +- Perform CRUD actions on alerts produced by rules + +---- + +Table of Contents + +- [Rule Registry](#rule-registry) +- [Role Based Access-Control](#rbac) + +## Rule Registry The rule registry plugin aims to make it easy for rule type producers to have their rules produce the data that they need to build rich experiences on top of a unified experience, without the risk of mapping conflicts. A rule registry creates a template, an ILM policy, and an alias. The template mappings can be configured. It also injects a client scoped to these indices. @@ -78,3 +94,102 @@ The following fields are available in the root rule registry: - `kibana.rac.alert.severity.value`: the severity of the alert, as a numerical value, which allows sorting. This list is not final - just a start. Field names might change or moved to a scoped registry. If we implement log and sequence based rule types the list of fields will grow. If a rule type needs additional fields, the recommendation would be to have the field in its own registry first (or in its producer’s registry), and if usage is more broadly adopted, it can be moved to the root registry. + +## Role Based Access-Control + +Rules registered through the rule registry produce `alerts` that are indexed into the `.alerts` index. Using the `producer` defined in the rule registry, these alerts inheret the `producer` property which is used in the auth to determine whether a user has access to these alerts and what operations they can perform on them. + +Users will need to be granted access to these `alerts`. When registering a feature in Kibana you can specify multiple types of privileges which are granted to users when they're assigned certain roles. Assuming your feature generates `alerts`, you'll want to control which roles have all/read privileges for these alerts that are scoped to your feature. For example, the `security_solution` plugin allows users to create rules that generate `alerts`, so does `observability`. The `security_solution` plugin only wants to grant it's users access to `alerts` belonging to `security_solution`. However, a user may have access to numerous `alerts` like `['security_solution', 'observability']`. + +You can control all of these abilities by assigning privileges to Alerts from within your own feature, for example: + +```typescript +features.registerKibanaFeature({ + id: 'my-application-id', + name: 'My Application', + app: [], + privileges: { + all: { + alerts: { + all: [ + // grant `all` over our own types + 'my-application-id.my-feature', + 'my-application-id.my-restricted-alert-type', + // grant `all` over the built-in IndexThreshold + '.index-threshold', + // grant `all` over Uptime's TLS AlertType + 'xpack.uptime.alerts.actionGroups.tls' + ], + }, + }, + read: { + alerts: { + read: [ + // grant `read` over our own type + 'my-application-id.my-feature', + // grant `read` over the built-in IndexThreshold + '.index-threshold', + // grant `read` over Uptime's TLS AlertType + 'xpack.uptime.alerts.actionGroups.tls' + ], + }, + }, + }, +}); +``` + +In this example we can see the following: +- Our feature grants any user who's assigned the `all` role in our feature the `all` role in the Alerting framework over every alert of the `my-application-id.my-alert-type` type which is created _inside_ the feature. What that means is that this privilege will allow the user to execute any of the `all` operations (listed below) on these alerts as long as their `consumer` is `my-application-id`. Below that you'll notice we've done the same with the `read` role, which is grants the Alerting Framework's `read` role privileges over these very same alerts. +- In addition, our feature grants the same privileges over any alert of type `my-application-id.my-restricted-alert-type`, which is another hypothetical alertType registered by this feature. It's worth noting though that this type has been omitted from the `read` role. What this means is that only users with the `all` role will be able to interact with alerts of this type. +- Next, lets look at the `.index-threshold` and `xpack.uptime.alerts.actionGroups.tls` types. These have been specified in both `read` and `all`, which means that all the users in the feature will gain privileges over alerts of these types (as long as their `consumer` is `my-application-id`). The difference between these two and the previous two is that they are _produced_ by other features! `.index-threshold` is a built-in type, provided by the _Built-In Alerts_ feature, and `xpack.uptime.alerts.actionGroups.tls` is an AlertType provided by the _Uptime_ feature. Specifying these type here tells the Alerting Framework that as far as the `my-application-id` feature is concerned, the user is privileged to use them (with `all` and `read` applied), but that isn't enough. Using another feature's AlertType is only possible if both the producer of the AlertType, and the consumer of the AlertType, explicitly grant privileges to do so. In this case, the _Built-In Alerts_ & _Uptime_ features would have to explicitly add these privileges to a role and this role would have to be granted to this user. + +It's important to note that any role can be granted a mix of `all` and `read` privileges accross multiple type, for example: + +```typescript +features.registerKibanaFeature({ + id: 'my-application-id', + name: 'My Application', + app: [], + privileges: { + all: { + app: ['my-application-id', 'kibana'], + savedObject: { + all: [], + read: [], + }, + ui: [], + api: [], + }, + read: { + app: ['lens', 'kibana'], + alerting: { + all: [ + 'my-application-id.my-alert-type' + ], + read: [ + 'my-application-id.my-restricted-alert-type' + ], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + api: [], + }, + }, +}); +``` + +In the above example, you note that instead of denying users with the `read` role any access to the `my-application-id.my-restricted-alert-type` type, we've decided that these users _should_ be granted `read` privileges over the _resitricted_ AlertType. +As part of that same change, we also decided that not only should they be allowed to `read` the _restricted_ AlertType, but actually, despite having `read` privileges to the feature as a whole, we do actually want to allow them to create our basic 'my-application-id.my-alert-type' AlertType, as we consider it an extension of _reading_ data in our feature, rather than _writing_ it. + +### `read` privileges vs. `all` privileges +When a user is granted the `read` role in for Alerts, they will be able to execute the following api calls: +- `get` +- `find` + +When a user is granted the `all` role in the Alerting Framework, they will be able to execute all of the `read` privileged api calls, but in addition they'll be granted the following calls: +- `update` + +Attempting to execute any operation the user isn't privileged to execute will result in an Authorization error thrown by the AlertsClient. \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/common/field_map/base_rule_field_map.ts b/x-pack/plugins/rule_registry/common/field_map/base_rule_field_map.ts index 22a74212d2ce0..d9e52a5801f0c 100644 --- a/x-pack/plugins/rule_registry/common/field_map/base_rule_field_map.ts +++ b/x-pack/plugins/rule_registry/common/field_map/base_rule_field_map.ts @@ -28,6 +28,7 @@ export const baseRuleFieldMap = { 'kibana.rac.alert.severity.level': { type: 'keyword' }, 'kibana.rac.alert.severity.value': { type: 'long' }, 'kibana.rac.alert.status': { type: 'keyword' }, + 'kibana.rac.alert.owner': { type: 'keyword' }, } as const; export type BaseRuleFieldMap = typeof baseRuleFieldMap; diff --git a/x-pack/plugins/rule_registry/kibana.json b/x-pack/plugins/rule_registry/kibana.json index ec2b366f739e6..e8f857396cdd7 100644 --- a/x-pack/plugins/rule_registry/kibana.json +++ b/x-pack/plugins/rule_registry/kibana.json @@ -8,7 +8,9 @@ ], "requiredPlugins": [ "alerting", - "triggersActionsUi" + "triggersActionsUi", + "features", + "security" ], "server": true, "ui": true diff --git a/x-pack/plugins/rule_registry/server/authorization/audit_logger.mock.ts b/x-pack/plugins/rule_registry/server/authorization/audit_logger.mock.ts new file mode 100644 index 0000000000000..9a8cf4d13d24b --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/audit_logger.mock.ts @@ -0,0 +1,24 @@ +/* + * 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 { RacAuthorizationAuditLogger } from './audit_logger'; + +const createRacAuthorizationAuditLoggerMock = () => { + const mocked = ({ + getAuthorizationMessage: jest.fn(), + racAuthorizationFailure: jest.fn(), + racUnscopedAuthorizationFailure: jest.fn(), + racAuthorizationSuccess: jest.fn(), + } as unknown) as jest.Mocked; + return mocked; +}; + +export const alertsAuthorizationAuditLoggerMock: { + create: () => jest.Mocked; +} = { + create: createRacAuthorizationAuditLoggerMock, +}; diff --git a/x-pack/plugins/rule_registry/server/authorization/audit_logger.test.ts b/x-pack/plugins/rule_registry/server/authorization/audit_logger.test.ts new file mode 100644 index 0000000000000..6661004a6bd9a --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/audit_logger.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { EventType } from '../../../security/server'; + +import { RacAuthorizationAuditLogger } from './audit_logger'; + +const createMockAuditLogger = () => { + return { + log: jest.fn(), + }; +}; + +describe(`#constructor`, () => { + test('initializes a noop auditLogger if security logger is unavailable', () => { + const racAuditLogger = new RacAuthorizationAuditLogger(undefined); + + const username = 'foo-user'; + const owner = 'myApp'; + const operation = 'create'; + const type = EventType.ACCESS; + const error = new Error('my bad'); + expect(() => { + racAuditLogger.racAuthorizationFailure({ + username, + owner, + operation, + type, + error, + }); + + racAuditLogger.racAuthorizationSuccess({ + username, + owner, + operation, + type, + }); + }).not.toThrow(); + }); +}); + +describe(`#racAuthorizationSuccess`, () => { + test('logs auth success of operation', () => { + const auditLogger = createMockAuditLogger(); + const racAuditLogger = new RacAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const owner = 'myApp'; + const operation = 'create'; + const type = EventType.ACCESS; + + racAuditLogger.racAuthorizationSuccess({ username, operation, owner, type }); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "event": Object { + "action": "rac_authorization_success", + "category": "authentication", + "outcome": "success", + "type": "access", + }, + "message": "Authorized to create \\"myApp\\" alert\\"", + "user": Object { + "name": "foo-user", + }, + }, + ] + `); + }); +}); + +describe(`#racUnscopedAuthorizationFailure`, () => { + test('logs auth failure of operation', () => { + const auditLogger = createMockAuditLogger(); + const racAuditLogger = new RacAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const operation = 'create'; + const type = EventType.ACCESS; + + racAuditLogger.racUnscopedAuthorizationFailure({ username, operation, type }); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "event": Object { + "action": "rac_authorization_failure", + "category": "authentication", + "outcome": "failure", + "type": "access", + }, + "message": "Unauthorized to create any alerts", + "user": Object { + "name": "foo-user", + }, + }, + ] + `); + }); +}); + +describe(`#racAuthorizationFailure`, () => { + test('logs auth failure', () => { + const auditLogger = createMockAuditLogger(); + const racAuditLogger = new RacAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const owner = 'myApp'; + const operation = 'create'; + const type = EventType.ACCESS; + const error = new Error('my bad'); + + racAuditLogger.racAuthorizationFailure({ username, owner, operation, type, error }); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "error": Object { + "code": "Error", + "message": "my bad", + }, + "event": Object { + "action": "rac_authorization_failure", + "category": "authentication", + "outcome": "failure", + "type": "access", + }, + "message": "Unauthorized to create \\"myApp\\" alert\\"", + "user": Object { + "name": "foo-user", + }, + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/authorization/audit_logger.ts b/x-pack/plugins/rule_registry/server/authorization/audit_logger.ts new file mode 100644 index 0000000000000..122f1f0f5f341 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/audit_logger.ts @@ -0,0 +1,118 @@ +/* + * 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 { AuditLogger, EventCategory, EventOutcome, EventType } from '../../../security/server'; + +export enum AuthorizationResult { + Unauthorized = 'Unauthorized', + Authorized = 'Authorized', +} + +export class RacAuthorizationAuditLogger { + private readonly auditLogger: AuditLogger; + + constructor(auditLogger: AuditLogger = { log() {} }) { + this.auditLogger = auditLogger; + } + + public getAuthorizationMessage( + authorizationResult: AuthorizationResult, + owner: string, + operation: string + ): string { + return `${authorizationResult} to ${operation} "${owner}" alert"`; + } + + public racAuthorizationFailure({ + username, + owner, + operation, + type, + error, + }: { + username: string; + owner: string; + operation: string; + type: EventType; + error?: Error; + }): string { + const message = this.getAuthorizationMessage( + AuthorizationResult.Unauthorized, + owner, + operation + ); + this.auditLogger.log({ + message, + event: { + action: 'rac_authorization_failure', + category: EventCategory.DATABASE, + type, + outcome: EventOutcome.FAILURE, + }, + user: { + name: username, + }, + error: error && { + code: error.name, + message: error.message, + }, + }); + return message; + } + + public racUnscopedAuthorizationFailure({ + username, + operation, + type, + }: { + username: string; + operation: string; + type: EventType; + }): string { + const message = `Unauthorized to ${operation} any alerts`; + this.auditLogger.log({ + message, + event: { + action: 'rac_authorization_failure', + category: EventCategory.DATABASE, + type, + outcome: EventOutcome.FAILURE, + }, + user: { + name: username, + }, + }); + return message; + } + + public racAuthorizationSuccess({ + username, + owner, + operation, + type, + }: { + username: string; + owner: string; + operation: string; + type: EventType; + }): string { + const message = this.getAuthorizationMessage(AuthorizationResult.Authorized, owner, operation); + this.auditLogger.log({ + message, + event: { + action: 'rac_authorization_success', + category: EventCategory.DATABASE, + type, + outcome: EventOutcome.SUCCESS, + }, + user: { + name: username, + }, + }); + return message; + } +} diff --git a/x-pack/plugins/rule_registry/server/authorization/rac_authorization.test.ts b/x-pack/plugins/rule_registry/server/authorization/rac_authorization.test.ts new file mode 100644 index 0000000000000..254e796d757c7 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/rac_authorization.test.ts @@ -0,0 +1,1092 @@ +/* + * 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 { KibanaRequest } from 'kibana/server'; + +import { securityMock } from '../../../../plugins/security/server/mocks'; +import { + PluginStartContract as FeaturesStartContract, + KibanaFeature, +} from '../../../features/server'; +import { featuresPluginMock } from '../../../features/server/mocks'; +import { RacAuthorization, WriteOperations, ReadOperations } from './rac_authorization'; +import { alertsAuthorizationAuditLoggerMock } from './audit_logger.mock'; +import uuid from 'uuid'; + +jest.mock('./audit_logger'); + +const features: jest.Mocked = featuresPluginMock.createStart(); +const request = {} as KibanaRequest; + +const auditLogger = alertsAuthorizationAuditLoggerMock.create(); + +const getSpace = jest.fn(); + +const mockAuthorizationAction = (owner: string, operation: string) => `${owner}/${operation}`; +function mockSecurity() { + const security = securityMock.createSetup(); + const authorization = security.authz; + (authorization.actions.rac.get as jest.MockedFunction< + typeof authorization.actions.rac.get + >).mockImplementation(mockAuthorizationAction); + authorization.mode.useRbacForRequest.mockReturnValue(true); + return { authorization }; +} + +function mockFeature(appName: string, owner?: string) { + return new KibanaFeature({ + id: appName, + name: appName, + app: [], + category: { id: 'foo', label: 'foo' }, + ...(owner + ? { + rac: [owner], + } + : {}), + privileges: { + all: { + ...(owner + ? { + rac: { + all: [owner], + }, + } + : {}), + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + ...(owner + ? { + rac: { + read: [owner], + }, + } + : {}), + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }); +} + +function mockFeatureWithSubFeature(appName: string, owner: string) { + return new KibanaFeature({ + id: appName, + name: appName, + app: [], + category: { id: 'foo', label: 'foo' }, + ...(owner + ? { + alerting: [owner], + } + : {}), + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: appName, + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'doSomethingRacRelated', + name: 'sub feature rac', + includeIn: 'all', + alerting: { + all: [owner], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['doSomethingRacRelated'], + }, + { + id: 'doSomethingRacRelated', + name: 'sub feature rac', + includeIn: 'read', + alerting: { + read: [owner], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['doSomethingRacRelated'], + }, + ], + }, + ], + }, + ], + }); +} + +const myAppFeature = mockFeature('myApp', 'securitySolution'); +const myOtherAppFeature = mockFeature('myOtherApp', 'securitySolution'); +const myAppWithSubFeature = mockFeatureWithSubFeature('myAppWithSubFeature', 'securitySolution'); +const myFeatureWithoutAlerting = mockFeature('myOtherApp'); + +beforeEach(() => { + jest.resetAllMocks(); + + features.getKibanaFeatures.mockReturnValue([ + myAppFeature, + myOtherAppFeature, + myAppWithSubFeature, + myFeatureWithoutAlerting, + ]); + getSpace.mockResolvedValue(undefined); +}); + +describe('RacAuthorization', () => { + describe('create', () => { + test(`fetches the user's current space`, async () => { + const { authorization } = mockSecurity(); + + const space = { + id: uuid.v4(), + name: uuid.v4(), + disabledFeatures: [], + }; + getSpace.mockResolvedValue(space); + + RacAuthorization.create({ + request, + authorization, + features, + auditLogger, + getSpace, + isAuthEnabled: true, + }); + + expect(getSpace).toHaveBeenCalledWith(request); + }); + }); + + describe('ensureAuthorized', () => { + test('is a no-op when there is no authorization api', async () => { + const racAuthorization = await RacAuthorization.create({ + request, + features, + auditLogger, + getSpace, + isAuthEnabled: true, + }); + + await expect( + racAuthorization.ensureAuthorized('securitySolution', WriteOperations.Update) + ).resolves.toBeUndefined(); + }); + + test('is a no-op when the security license is disabled', async () => { + const { authorization } = mockSecurity(); + authorization.mode.useRbacForRequest.mockReturnValue(false); + const racAuthorization = await RacAuthorization.create({ + request, + features, + authorization, + auditLogger, + getSpace, + isAuthEnabled: true, + }); + + await expect( + racAuthorization.ensureAuthorized('securitySolution', WriteOperations.Update) + ).resolves.toBeUndefined(); + }); + + test('ensures the user has privileges to execute update operation with specified spaceId and owner', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction< + ReturnType + > = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const racAuthorization = await RacAuthorization.create({ + request, + features, + authorization, + auditLogger, + getSpace, + isAuthEnabled: true, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: { kibana: [] }, + }); + + await racAuthorization.ensureAuthorized('securitySolution', WriteOperations.Update); + + expect(authorization.actions.rac.get).toHaveBeenCalledWith('securitySolution', 'update'); + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: [mockAuthorizationAction('securitySolution', 'update')], + }); + + expect(auditLogger.racAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.racAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.racAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "operation": "update", + "owner": "securitySolution", + "type": "access", + "username": "some-user", + }, + ] + `); + }); + + test('ensures the user has privileges to execute find operation with specified spaceId and owner', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction< + ReturnType + > = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const racAuthorization = await RacAuthorization.create({ + request, + features, + authorization, + auditLogger, + getSpace, + isAuthEnabled: true, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: { kibana: [] }, + }); + + await racAuthorization.ensureAuthorized('securitySolution', ReadOperations.Find); + + expect(authorization.actions.rac.get).toHaveBeenCalledWith('securitySolution', 'find'); + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: [mockAuthorizationAction('securitySolution', 'find')], + }); + + expect(auditLogger.racAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.racAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.racAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "operation": "find", + "owner": "securitySolution", + "type": "access", + "username": "some-user", + }, + ] + `); + }); + + test('throws if user lacks the required privileges', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction< + ReturnType + > = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const racAuthorization = await RacAuthorization.create({ + request, + authorization, + features, + auditLogger, + getSpace, + isAuthEnabled: true, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('securitySolution', 'update'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('observability', 'update'), + authorized: true, + }, + ], + }, + }); + + await expect( + racAuthorization.ensureAuthorized('myOtherApp', WriteOperations.Update) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Forbidden"`); + + expect(auditLogger.racAuthorizationSuccess).not.toHaveBeenCalled(); + expect(auditLogger.racAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(auditLogger.racAuthorizationFailure.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "operation": "update", + "owner": "myOtherApp", + "type": "access", + "username": "some-user", + }, + ] + `); + }); + }); +}); +// xdescribe('getFindAuthorizationFilter', () => { +// const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType, mySecondAppAlertType]); + +// test('omits filter when there is no authorization api', async () => { +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// features, +// auditLogger, +// getSpace, +// }); + +// const { +// filter, +// ensureAlertTypeIsAuthorized, +// } = await racAuthorization.getFindAuthorizationFilter(); + +// expect(() => ensureAlertTypeIsAuthorized('someMadeUpType', 'myApp')).not.toThrow(); + +// expect(filter).toEqual(undefined); +// }); + +// test('ensureAlertTypeIsAuthorized is no-op when there is no authorization api', async () => { +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// features, +// auditLogger, +// getSpace, +// }); + +// const { ensureAlertTypeIsAuthorized } = await racAuthorization.getFindAuthorizationFilter(); + +// ensureAlertTypeIsAuthorized('someMadeUpType', 'myApp'); + +// expect(auditLogger.racAuthorizationSuccess).not.toHaveBeenCalled(); +// expect(auditLogger.racAuthorizationFailure).not.toHaveBeenCalled(); +// }); + +// test('creates a filter based on the privileged types', async () => { +// const { authorization } = mockSecurity(); +// const checkPrivileges: jest.MockedFunction< +// ReturnType +// > = jest.fn(); +// authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); +// checkPrivileges.mockResolvedValueOnce({ +// username: 'some-user', +// hasAllRequested: true, +// privileges: { kibana: [] }, +// }); + +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// authoowners, +// features, +// auditLogger, +// getSpace, +// }); +// alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + +// // TODO: once issue https://github.com/elastic/kibana/issues/89473 is +// // resolved, we can start using this code again, instead of toMatchSnapshot(): +// // +// // expect((await racAuthorization.getFindAuthorizationFilter()).filter).toEqual( +// // esKuery.fromKueryExpression( +// // `((alert.attributes.alertTypeId:myAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:myOtherAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:mySecondAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)))` +// // ) +// // ); + +// // This code is the replacement code for above +// expect((await racAuthorization.getFindAuthorizationFilter()).filter).toMatchSnapshot(); + +// expect(auditLogger.racAuthorizationSuccess).not.toHaveBeenCalled(); +// }); + +// test('creates an `ensureAlertTypeIsAuthorized` function which throws if type is unauthorized', 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', 'find'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), +// authorized: false, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), +// authorized: false, +// }, +// ], +// }, +// }); + +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// authoowners, +// features, +// auditLogger, +// getSpace, +// }); +// alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + +// const { ensureAlertTypeIsAuthorized } = await racAuthorization.getFindAuthorizationFilter(); +// expect(() => { +// ensureAlertTypeIsAuthorized('myAppAlertType', 'myOtherApp'); +// }).toThrowErrorMatchingInlineSnapshot( +// `"Unauthorized to find a \\"myAppAlertType\\" alert for \\"myOtherApp\\""` +// ); + +// expect(auditLogger.racAuthorizationSuccess).not.toHaveBeenCalled(); +// expect(auditLogger.racAuthorizationFailure).toHaveBeenCalledTimes(1); +// expect(auditLogger.racAuthorizationFailure.mock.calls[0]).toMatchInlineSnapshot(` +// Array [ +// "some-user", +// "myAppAlertType", +// 0, +// "myOtherApp", +// "find", +// ] +// `); +// }); + +// test('creates an `ensureAlertTypeIsAuthorized` function which is no-op if type is authorized', 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', 'find'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), +// authorized: false, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), +// authorized: true, +// }, +// ], +// }, +// }); + +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// authoowners, +// features, +// auditLogger, +// getSpace, +// }); +// alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + +// const { ensureAlertTypeIsAuthorized } = await racAuthorization.getFindAuthorizationFilter(); +// expect(() => { +// ensureAlertTypeIsAuthorized('myAppAlertType', 'myOtherApp'); +// }).not.toThrow(); + +// expect(auditLogger.racAuthorizationSuccess).not.toHaveBeenCalled(); +// expect(auditLogger.racAuthorizationFailure).not.toHaveBeenCalled(); +// }); + +// test('creates an `logSuccessfulAuthorization` function which logs every authorized type', 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', 'find'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), +// authorized: false, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('mySecondAppAlertType', 'myApp', 'find'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('mySecondAppAlertType', 'myOtherApp', 'find'), +// authorized: true, +// }, +// ], +// }, +// }); + +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// authoowners, +// features, +// auditLogger, +// getSpace, +// }); +// alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + +// const { +// ensureAlertTypeIsAuthorized, +// logSuccessfulAuthorization, +// } = await racAuthorization.getFindAuthorizationFilter(); +// expect(() => { +// ensureAlertTypeIsAuthorized('myAppAlertType', 'myOtherApp'); +// ensureAlertTypeIsAuthorized('mySecondAppAlertType', 'myOtherApp'); +// ensureAlertTypeIsAuthorized('myAppAlertType', 'myOtherApp'); +// }).not.toThrow(); + +// expect(auditLogger.racAuthorizationSuccess).not.toHaveBeenCalled(); +// expect(auditLogger.racAuthorizationFailure).not.toHaveBeenCalled(); + +// logSuccessfulAuthorization(); + +// expect(auditLogger.alertsBulkAuthorizationSuccess).toHaveBeenCalledTimes(1); +// expect(auditLogger.alertsBulkAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` +// Array [ +// "some-user", +// Array [ +// Array [ +// "myAppAlertType", +// "myOtherApp", +// ], +// Array [ +// "mySecondAppAlertType", +// "myOtherApp", +// ], +// ], +// 0, +// "find", +// ] +// `); +// }); +// }); + +// xdescribe('filterByAlertTypeAuthorization', () => { +// const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType]); + +// test('augments a list of types with all features when there is no authorization api', async () => { +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// features, +// auditLogger, +// getSpace, +// }); +// alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + +// await expect( +// racAuthorization.filterByAlertTypeAuthorization( +// new Set([myAppAlertType, myOtherAppAlertType]), +// [WriteOperations.Update] +// ) +// ).resolves.toMatchInlineSnapshot(` +// Set { +// Object { +// "actionGroups": Array [], +// "actionVariables": undefined, +// "authorizedConsumers": Object { +// "alerts": Object { +// "all": true, +// "read": true, +// }, +// "myApp": Object { +// "all": true, +// "read": true, +// }, +// "myAppWithSubFeature": Object { +// "all": true, +// "read": true, +// }, +// "myOtherApp": Object { +// "all": true, +// "read": true, +// }, +// }, +// "defaultActionGroupId": "default", +// "enabledInLicense": true, +// "id": "myAppAlertType", +// "minimumLicenseRequired": "basic", +// "name": "myAppAlertType", +// "producer": "myApp", +// "recoveryActionGroup": Object { +// "id": "recovered", +// "name": "Recovered", +// }, +// }, +// Object { +// "actionGroups": Array [], +// "actionVariables": undefined, +// "authorizedConsumers": Object { +// "alerts": Object { +// "all": true, +// "read": true, +// }, +// "myApp": Object { +// "all": true, +// "read": true, +// }, +// "myAppWithSubFeature": Object { +// "all": true, +// "read": true, +// }, +// "myOtherApp": Object { +// "all": true, +// "read": true, +// }, +// }, +// "defaultActionGroupId": "default", +// "enabledInLicense": true, +// "id": "myOtherAppAlertType", +// "minimumLicenseRequired": "basic", +// "name": "myOtherAppAlertType", +// "producer": "myOtherApp", +// "recoveryActionGroup": Object { +// "id": "recovered", +// "name": "Recovered", +// }, +// }, +// } +// `); +// }); + +// test('augments a list of types with consumers under which the operation is authorized', 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', 'create'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), +// authorized: false, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), +// authorized: true, +// }, +// ], +// }, +// }); + +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// authoowners, +// features, +// auditLogger, +// getSpace, +// }); +// alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + +// await expect( +// racAuthorization.filterByAlertTypeAuthorization( +// new Set([myAppAlertType, myOtherAppAlertType]), +// [WriteOperations.Update] +// ) +// ).resolves.toMatchInlineSnapshot(` +// Set { +// Object { +// "actionGroups": Array [], +// "actionVariables": undefined, +// "authorizedConsumers": Object { +// "myApp": Object { +// "all": true, +// "read": true, +// }, +// }, +// "defaultActionGroupId": "default", +// "enabledInLicense": true, +// "id": "myOtherAppAlertType", +// "minimumLicenseRequired": "basic", +// "name": "myOtherAppAlertType", +// "producer": "myOtherApp", +// "recoveryActionGroup": Object { +// "id": "recovered", +// "name": "Recovered", +// }, +// }, +// Object { +// "actionGroups": Array [], +// "actionVariables": undefined, +// "authorizedConsumers": Object { +// "alerts": Object { +// "all": true, +// "read": true, +// }, +// "myApp": Object { +// "all": true, +// "read": true, +// }, +// "myOtherApp": Object { +// "all": true, +// "read": true, +// }, +// }, +// "defaultActionGroupId": "default", +// "enabledInLicense": true, +// "id": "myAppAlertType", +// "minimumLicenseRequired": "basic", +// "name": "myAppAlertType", +// "producer": "myApp", +// "recoveryActionGroup": Object { +// "id": "recovered", +// "name": "Recovered", +// }, +// }, +// } +// `); +// }); + +// test('authorizes user under the Alerts consumer when they are authorized by the producer', 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('myAppAlertType', 'myApp', 'create'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), +// authorized: false, +// }, +// ], +// }, +// }); + +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// authoowners, +// features, +// auditLogger, +// getSpace, +// }); +// alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + +// await expect( +// racAuthorization.filterByAlertTypeAuthorization(new Set([myAppAlertType]), [ +// WriteOperations.Update, +// ]) +// ).resolves.toMatchInlineSnapshot(` +// Set { +// Object { +// "actionGroups": Array [], +// "actionVariables": undefined, +// "authorizedConsumers": Object { +// "alerts": Object { +// "all": true, +// "read": true, +// }, +// "myApp": Object { +// "all": true, +// "read": true, +// }, +// }, +// "defaultActionGroupId": "default", +// "enabledInLicense": true, +// "id": "myAppAlertType", +// "minimumLicenseRequired": "basic", +// "name": "myAppAlertType", +// "producer": "myApp", +// "recoveryActionGroup": Object { +// "id": "recovered", +// "name": "Recovered", +// }, +// }, +// } +// `); +// }); + +// test('augments a list of types with consumers under which multiple operations are authorized', 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', 'create'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), +// authorized: false, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), +// authorized: false, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), +// authorized: false, +// }, +// { +// privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'get'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'get'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'get'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'get'), +// authorized: true, +// }, +// ], +// }, +// }); + +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// authoowners, +// features, +// auditLogger, +// getSpace, +// }); +// alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + +// await expect( +// racAuthorization.filterByAlertTypeAuthorization( +// new Set([myAppAlertType, myOtherAppAlertType]), +// [WriteOperations.Update, ReadOperations.Get] +// ) +// ).resolves.toMatchInlineSnapshot(` +// Set { +// Object { +// "actionGroups": Array [], +// "actionVariables": undefined, +// "authorizedConsumers": Object { +// "alerts": Object { +// "all": false, +// "read": true, +// }, +// "myApp": Object { +// "all": true, +// "read": true, +// }, +// "myOtherApp": Object { +// "all": false, +// "read": true, +// }, +// }, +// "defaultActionGroupId": "default", +// "enabledInLicense": true, +// "id": "myOtherAppAlertType", +// "minimumLicenseRequired": "basic", +// "name": "myOtherAppAlertType", +// "producer": "myOtherApp", +// "recoveryActionGroup": Object { +// "id": "recovered", +// "name": "Recovered", +// }, +// }, +// Object { +// "actionGroups": Array [], +// "actionVariables": undefined, +// "authorizedConsumers": Object { +// "alerts": Object { +// "all": false, +// "read": true, +// }, +// "myApp": Object { +// "all": false, +// "read": true, +// }, +// "myOtherApp": Object { +// "all": false, +// "read": true, +// }, +// }, +// "defaultActionGroupId": "default", +// "enabledInLicense": true, +// "id": "myAppAlertType", +// "minimumLicenseRequired": "basic", +// "name": "myAppAlertType", +// "producer": "myApp", +// "recoveryActionGroup": Object { +// "id": "recovered", +// "name": "Recovered", +// }, +// }, +// } +// `); +// }); + +// test('omits types which have no consumers under which the operation is authorized', 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', 'create'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), +// authorized: true, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), +// authorized: false, +// }, +// { +// privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), +// authorized: false, +// }, +// ], +// }, +// }); + +// const racAuthorization = RacAuthorization.create({ +// request, +// authorization, +// authoowners, +// features, +// auditLogger, +// getSpace, +// }); +// alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + +// await expect( +// racAuthorization.filterByAlertTypeAuthorization( +// new Set([myAppAlertType, myOtherAppAlertType]), +// [WriteOperations.Update] +// ) +// ).resolves.toMatchInlineSnapshot(` +// Set { +// Object { +// "actionGroups": Array [], +// "actionVariables": undefined, +// "authorizedConsumers": Object { +// "alerts": Object { +// "all": true, +// "read": true, +// }, +// "myApp": Object { +// "all": true, +// "read": true, +// }, +// "myOtherApp": Object { +// "all": true, +// "read": true, +// }, +// }, +// "defaultActionGroupId": "default", +// "enabledInLicense": true, +// "id": "myOtherAppAlertType", +// "minimumLicenseRequired": "basic", +// "name": "myOtherAppAlertType", +// "producer": "myOtherApp", +// "recoveryActionGroup": Object { +// "id": "recovered", +// "name": "Recovered", +// }, +// }, +// } +// `); +// }); +// }); +// }); diff --git a/x-pack/plugins/rule_registry/server/authorization/rac_authorization.ts b/x-pack/plugins/rule_registry/server/authorization/rac_authorization.ts new file mode 100644 index 0000000000000..9310ab865da44 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/rac_authorization.ts @@ -0,0 +1,234 @@ +/* + * 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 Boom from '@hapi/boom'; + +import { KibanaRequest } from 'src/core/server'; +import { EventType, SecurityPluginStart } from '../../../security/server'; +import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; +import { Space } from '../../../spaces/server'; +import { KueryNode } from '../../../../../src/plugins/data/server'; +import { RacAuthorizationAuditLogger } from './audit_logger'; +import { getEnabledKibanaSpaceFeatures } from './utils'; + +export type GetSpaceFn = (request: KibanaRequest) => Promise; + +export enum ReadOperations { + Get = 'get', + Find = 'find', +} + +export enum WriteOperations { + Update = 'update', +} + +interface HasPrivileges { + read: boolean; + all: boolean; +} +export interface ConstructorOptions { + request: KibanaRequest; + authorization?: SecurityPluginStart['authz']; + owners: Set; + isAuthEnabled: boolean; + auditLogger: RacAuthorizationAuditLogger; +} + +export interface CreateOptions { + request: KibanaRequest; + authorization?: SecurityPluginStart['authz']; + isAuthEnabled: boolean; + auditLogger: RacAuthorizationAuditLogger; + getSpace: GetSpaceFn; + features: FeaturesPluginStart; +} + +export class RacAuthorization { + private readonly request: KibanaRequest; + private readonly authorization?: SecurityPluginStart['authz']; + private readonly auditLogger: RacAuthorizationAuditLogger; + private readonly featureOwners: Set; + private readonly isAuthEnabled: boolean; + + constructor({ request, authorization, owners, isAuthEnabled, auditLogger }: ConstructorOptions) { + this.request = request; + this.authorization = authorization; + this.featureOwners = owners; + this.isAuthEnabled = isAuthEnabled; + this.auditLogger = auditLogger; + } + + static async create({ + request, + authorization, + getSpace, + features, + isAuthEnabled, + auditLogger, + }: CreateOptions): Promise { + const owners = await getEnabledKibanaSpaceFeatures({ + getSpace, + request, + features, + }); + + console.error('ARE THERE ANY OWNERS???', owners); + + return new RacAuthorization({ request, authorization, owners, isAuthEnabled, auditLogger }); + } + + /** + * Determines whether the security license is disabled + */ + private shouldCheckAuthorization(): boolean { + return this.authorization?.mode?.useRbacForRequest(this.request) ?? false; + } + + public async ensureAuthorized(owner: string, operation: ReadOperations | WriteOperations) { + const { authorization } = this; + + // Does the owner the client sent up match with the KibanaFeatures structure + const isAvailableOwner = this.featureOwners.has(owner); + console.error('PROVIDED OWNER', owner); + console.error('THIS.FEATUREOWNERS', this.featureOwners); + console.error('IS AVAILABLE OWNER', isAvailableOwner); + console.error('AUTHORIZATION???', authorization); + console.error('THIS.SHOULDCHECKAUTHZ', this.shouldCheckAuthorization()); + + if (authorization != null && this.shouldCheckAuthorization()) { + const requiredPrivileges = [authorization.actions.rac.get(owner, operation)]; + console.error('REQUIRED PRIVILEGES', JSON.stringify(requiredPrivileges, null, 2)); + const checkPrivileges = authorization.checkPrivilegesDynamicallyWithRequest(this.request); + const { hasAllRequested, username, privileges } = await checkPrivileges({ + kibana: requiredPrivileges, + }); + if (!isAvailableOwner) { + /** + * Under most circumstances this would have been caught by `checkPrivileges` as + * a user can't have Privileges to an unknown consumer, but super users + * don't actually get "privilege checked" so the made up consumer *will* return + * as Privileged. + * This check will ensure we don't accidentally let these through + */ + throw Boom.forbidden( + this.auditLogger.racAuthorizationFailure({ + owner, + username, + operation, + type: EventType.ACCESS, + }) + ); + } + if (hasAllRequested) { + this.auditLogger.racAuthorizationSuccess({ + owner, + username, + operation, + type: EventType.ACCESS, + }); + } else { + const authorizedPrivileges = privileges.kibana.reduce((acc, privilege) => { + if (privilege.authorized) { + return [...acc, privilege.privilege]; + } + return acc; + }, []); + const unauthorizedPrivilages = requiredPrivileges.filter( + (privilege) => !authorizedPrivileges.includes(privilege) + ); + + throw Boom.forbidden( + this.auditLogger.racAuthorizationFailure({ + owner: unauthorizedPrivilages.join(','), + username, + operation, + type: EventType.ACCESS, + }) + ); + } + } else if (!isAvailableOwner) { + throw Boom.forbidden( + this.auditLogger.racAuthorizationFailure({ + owner, + username: '', + operation, + type: EventType.ACCESS, + }) + ); + } + } + + public async getFindAuthorizationFilter(): Promise<{ + filter?: KueryNode; + ensureAlertTypeIsAuthorized: (alertTypeId: string, consumer: string) => void; + logSuccessfulAuthorization: () => void; + }> { + // if (this.authorization && this.shouldCheckAuthorization()) { + // const { authorizedOwners } = await this.getAuthorizedOwners([ReadOperations.Find]); + // if (!authorizedOwners.length) { + // // TODO: Better error message, log error + // throw Boom.forbidden('Not authorized for this owner'); + // } + // return { + // filter: getOwnersFilter(savedObjectType, authorizedOwners), + // ensureAlertTypeIsAuthorized: (owner: string) => { + // if (!authorizedOwners.includes(owner)) { + // // TODO: log error + // throw Boom.forbidden('Not authorized for this owner'); + // } + // }, + // }; + // } + return { + ensureAlertTypeIsAuthorized: (alertTypeId: string, consumer: string) => {}, + logSuccessfulAuthorization: () => {}, + }; + } + + // private async getAuthorizedOwners( + // operations: Array + // ): Promise<{ + // username?: string; + // hasAllRequested: boolean; + // authorizedOwners: string[]; + // }> { + // const { securityAuth, featureCaseOwners } = this; + // if (securityAuth && this.shouldCheckAuthorization()) { + // const checkPrivileges = securityAuth.checkPrivilegesDynamicallyWithRequest(this.request); + // const requiredPrivileges = new Map(); + + // for (const owner of featureCaseOwners) { + // for (const operation of operations) { + // requiredPrivileges.set(securityAuth.actions.cases.get(owner, operation), [owner]); + // } + // } + + // const { hasAllRequested, username, privileges } = await checkPrivileges({ + // kibana: [...requiredPrivileges.keys()], + // }); + + // return { + // hasAllRequested, + // username, + // authorizedOwners: hasAllRequested + // ? Array.from(featureCaseOwners) + // : privileges.kibana.reduce((authorizedOwners, { authorized, privilege }) => { + // if (authorized && requiredPrivileges.has(privilege)) { + // const [owner] = requiredPrivileges.get(privilege)!; + // authorizedOwners.push(owner); + // } + + // return authorizedOwners; + // }, []), + // }; + // } else { + // return { + // hasAllRequested: true, + // authorizedOwners: Array.from(featureCaseOwners), + // }; + // } + // } +} diff --git a/x-pack/plugins/rule_registry/server/authorization/utils.test.ts b/x-pack/plugins/rule_registry/server/authorization/utils.test.ts new file mode 100644 index 0000000000000..8d3445389fa38 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/utils.test.ts @@ -0,0 +1,180 @@ +/* + * 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 { KibanaRequest } from 'kibana/server'; +import uuid from 'uuid'; +import { featuresPluginMock } from '../../../features/server/mocks'; +import { + PluginStartContract as FeaturesStartContract, + KibanaFeature, +} from '../../../features/server'; +import { getEnabledKibanaSpaceFeatures } from './utils'; + +function mockFeature(appName: string, owner?: string) { + return new KibanaFeature({ + id: appName, + name: appName, + app: [], + category: { id: 'foo', label: 'foo' }, + ...(owner + ? { + rac: [owner], + } + : {}), + privileges: { + all: { + ...(owner + ? { + rac: { + all: [owner], + }, + } + : {}), + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + ...(owner + ? { + rac: { + read: [owner], + }, + } + : {}), + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }); +} + +function mockFeatureWithSubFeature(appName: string, owner: string) { + return new KibanaFeature({ + id: appName, + name: appName, + app: [], + category: { id: 'foo', label: 'foo' }, + ...(owner + ? { + rac: [owner], + } + : {}), + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: appName, + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'doSomethingRacRelated', + name: 'sub feature rac', + includeIn: 'all', + rac: { + all: [owner], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['doSomethingRacRelated'], + }, + { + id: 'doSomethingRacRelated', + name: 'sub feature rac', + includeIn: 'read', + rac: { + read: [owner], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['doSomethingRacRelated'], + }, + ], + }, + ], + }, + ], + }); +} +const features: jest.Mocked = featuresPluginMock.createStart(); +const request = {} as KibanaRequest; +const getSpace = jest.fn(); + +const myAppFeature = mockFeature('myApp', 'securitySolution'); +const myAppWithSubFeature = mockFeatureWithSubFeature('myAppWithSubFeature', 'observability'); +const myOtherAppFeature = mockFeature('myOtherApp', 'observability'); + +describe('rac/authorization utils', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('#getEnabledKibanaSpaceFeatures', () => { + test('returns empty set if no enabled features found', async () => { + const space = { + id: uuid.v4(), + name: uuid.v4(), + disabledFeatures: ['myApp'], + }; + features.getKibanaFeatures.mockReturnValue([myAppFeature]); + const enabledFeatures = await getEnabledKibanaSpaceFeatures({ + request, + features, + getSpace: getSpace.mockResolvedValue(space), + }); + const expectedResult = new Set(); + + expect(enabledFeatures).toEqual(expectedResult); + }); + + test('returns set of enabled kibana features', async () => { + const space = { + id: uuid.v4(), + name: uuid.v4(), + disabledFeatures: ['myApp'], + }; + features.getKibanaFeatures.mockReturnValue([ + myAppFeature, + myOtherAppFeature, + myAppWithSubFeature, + ]); + const enabledFeatures = await getEnabledKibanaSpaceFeatures({ + request, + features, + getSpace: getSpace.mockResolvedValue(space), + }); + const expectedResult = new Set(['observability']); + + expect(enabledFeatures).toEqual(expectedResult); + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/authorization/utils.ts b/x-pack/plugins/rule_registry/server/authorization/utils.ts new file mode 100644 index 0000000000000..22187d57e6515 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/utils.ts @@ -0,0 +1,95 @@ +/* + * 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 { remove, uniq } from 'lodash'; + +import { KibanaRequest } from 'src/core/server'; + +import { nodeBuilder } from '../../../../../src/plugins/data/common'; +import { KueryNode } from '../../../../../src/plugins/data/server'; +import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; +import { GetSpaceFn } from './rac_authorization'; + +export const getEnabledKibanaSpaceFeatures = async ({ + getSpace, + request, + features, +}: { + request: KibanaRequest; + getSpace: GetSpaceFn; + features: FeaturesPluginStart; +}): Promise> => { + try { + console.error('GETSPACE', getSpace); + const disabledUserSpaceFeatures = new Set((await getSpace(request))?.disabledFeatures ?? []); + console.error('DISABLED USER SPACE FEATURES', disabledUserSpaceFeatures); + // Filter through all user Kibana features to find corresponding enabled + // RAC feature owners like 'security-solution' or 'observability' + const owners = await new Set( + features + .getKibanaFeatures() + // get all the rac 'owners' that aren't disabled + .filter(({ id }) => !disabledUserSpaceFeatures.has(id)) + .flatMap((feature) => { + console.error('FEATURE.RAC', feature.rac); + return feature.rac ?? []; + }) + ); + console.error('INTERNAL OWNERS', owners); + return owners; + } catch (error) { + console.error('GETENABLEDKIBANASPACEFEAUTRES THREW AN ERROR'); + return new Set(); + } +}; + +export const getOwnersFilter = (owners: string[]): KueryNode => { + // const kqlQuery: Query = { + // language: 'kuery', + // query: filter, + // }; + // const config: EsQueryConfig = { + // allowLeadingWildcards: true, + // dateFormatTZ: 'Zulu', + // ignoreFilterIfFieldNotInIndex: false, + // queryStringOptions: { analyze_wildcard: true }, + // }; + // return esQuery.buildEsQuery(undefined, kqlQuery, [], config); + // return nodeBuilder.or( + // owners.reduce((query, owner) => { + // ensureFieldIsSafeForQuery('owner', owner); + // query.push(nodeBuilder.is(`${savedObjectType}.attributes.owner`, owner)); + // return query; + // }, []) + // ); +}; + +export const combineFilterWithAuthorizationFilter = ( + filter: KueryNode, + authorizationFilter: KueryNode +) => { + return nodeBuilder.and([filter, authorizationFilter]); +}; + +export const ensureFieldIsSafeForQuery = (field: string, value: string): boolean => { + const invalid = value.match(/([>=<\*:()]+|\s+)/g); + if (invalid) { + const whitespace = remove(invalid, (chars) => chars.trim().length === 0); + const errors = []; + if (whitespace.length) { + errors.push(`whitespace`); + } + if (invalid.length) { + errors.push(`invalid character${invalid.length > 1 ? `s` : ``}: ${invalid?.join(`, `)}`); + } + throw new Error(`expected ${field} not to include ${errors.join(' and ')}`); + } + return true; +}; + +export const includeFieldsRequiredForAuthentication = (fields: string[]): string[] => + uniq([...fields, 'owner']); diff --git a/x-pack/plugins/rule_registry/server/index.ts b/x-pack/plugins/rule_registry/server/index.ts index 9fd1408fcdb21..619671028a504 100644 --- a/x-pack/plugins/rule_registry/server/index.ts +++ b/x-pack/plugins/rule_registry/server/index.ts @@ -13,6 +13,7 @@ export { RuleRegistryPluginSetupContract } from './plugin'; export { createLifecycleRuleTypeFactory } from './rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory'; export { FieldMapOf } from './types'; export { ScopedRuleRegistryClient } from './rule_registry/create_scoped_rule_registry_client/types'; +export { RacRequestHandlerContext, RacApiRequestHandlerContext } from './types'; export const config = { schema: schema.object({ diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index 09df47c40a394..e652a334a5143 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -5,29 +5,74 @@ * 2.0. */ -import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server'; -import { PluginSetupContract as AlertingPluginSetupContract } from '../../alerting/server'; +import { + Logger, + PluginInitializerContext, + Plugin, + CoreSetup, + CoreStart, + SharedGlobalConfig, + KibanaRequest, + IContextProvider, +} from 'src/core/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { SecurityPluginSetup, SecurityPluginStart } from '../../security/server'; +import { + PluginSetupContract as AlertingPluginSetupContract, + PluginStartContract as AlertPluginStartContract, +} from '../../alerting/server'; +import { SpacesPluginStart } from '../../spaces/server'; +import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; + import { RuleRegistry } from './rule_registry'; import { defaultIlmPolicy } from './rule_registry/defaults/ilm_policy'; import { BaseRuleFieldMap, baseRuleFieldMap } from '../common'; +import { RacClientFactory } from './rac_client/rac_client_factory'; import { RuleRegistryConfig } from '.'; +import { + ContextProviderReturn, + RacApiRequestHandlerContext, + RacRequestHandlerContext, +} from './types'; +import { RacClient } from './rac_client/rac_client'; +export interface RacPluginsSetup { + security?: SecurityPluginSetup; + alerting: AlertingPluginSetupContract; +} +export interface RacPluginsStart { + security?: SecurityPluginStart; + spaces?: SpacesPluginStart; + features: FeaturesPluginStart; + alerting: AlertPluginStartContract; + getRacClientWithRequest(request: KibanaRequest): PublicMethodsOf; +} export type RuleRegistryPluginSetupContract = RuleRegistry; +// export type RacPluginSetupContract = RuleRegistry; export class RuleRegistryPlugin implements Plugin { + private readonly globalConfig: SharedGlobalConfig; + private readonly config: RuleRegistryConfig; + private readonly racClientFactory: RacClientFactory; + private security?: SecurityPluginSetup; + private readonly logger: Logger; + private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; + constructor(private readonly initContext: PluginInitializerContext) { this.initContext = initContext; + this.racClientFactory = new RacClientFactory(); + this.globalConfig = this.initContext.config.legacy.get(); + this.config = initContext.config.get(); + this.logger = initContext.logger.get('root'); + this.kibanaVersion = initContext.env.packageInfo.version; } - public setup( - core: CoreSetup, - plugins: { alerting: AlertingPluginSetupContract } - ): RuleRegistryPluginSetupContract { + public setup(core: CoreSetup, plugins: RacPluginsSetup): RuleRegistryPluginSetupContract { const globalConfig = this.initContext.config.legacy.get(); - const config = this.initContext.config.get(); - const logger = this.initContext.logger.get(); + this.security = plugins.security; + // RULE REGISTRY const rootRegistry = new RuleRegistry({ coreSetup: core, ilmPolicy: defaultIlmPolicy, @@ -37,13 +82,72 @@ export class RuleRegistryPlugin implements Plugin( + 'ruleRegistry', + this.createRouteHandlerContext() + ); + + const router = core.http.createRouter(); + // handler is called when '/path' resource is requested with `GET` method + router.get({ path: '/rac-myfakepath', validate: false }, async (context, req, res) => { + const racClient = await context.ruleRegistry?.getRacClient(); + // console.error(`WHATS IN THE RAC CLIENT`, racClient); + racClient?.get({ id: 'hello world' }); + return res.ok(); }); return rootRegistry; } - public start() {} + public start(core: CoreStart, plugins: RacPluginsStart) { + const { logger, security, racClientFactory } = this; + + racClientFactory.initialize({ + logger, + securityPluginSetup: security, + securityPluginStart: plugins.security, + getSpaceId(request: KibanaRequest) { + return plugins.spaces?.spacesService.getSpaceId(request); + }, + async getSpace(request: KibanaRequest) { + return plugins.spaces?.spacesService.getActiveSpace(request); + }, + features: plugins.features, + kibanaVersion: this.kibanaVersion, + esClient: core.elasticsearch.client.asInternalUser, + }); + + const getRacClientWithRequest = (request: KibanaRequest) => { + return racClientFactory!.create(request); + }; + + return { + getRacClientWithRequest, + alerting: plugins.alerting, + }; + } + + private createRouteHandlerContext = (): IContextProvider< + RacRequestHandlerContext, + 'ruleRegistry' + > => { + const { racClientFactory } = this; + return async function alertsRouteHandlerContext( + context, + request + ): Promise { + return { + getRacClient: async () => { + const createdClient = racClientFactory!.create(request); + return createdClient; + }, + }; + }; + }; public stop() {} } diff --git a/x-pack/plugins/rule_registry/server/rac_client/rac_client.ts b/x-pack/plugins/rule_registry/server/rac_client/rac_client.ts new file mode 100644 index 0000000000000..6ebca65213b89 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rac_client/rac_client.ts @@ -0,0 +1,410 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { + Logger, + PluginInitializerContext, + ElasticsearchClient, +} from '../../../../../src/core/server'; +import { + GrantAPIKeyResult as SecurityPluginGrantAPIKeyResult, + InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult, +} from '../../../security/server'; +import { SERVER_APP_ID } from '../../../security_solution/server'; + +import { + RacAuthorization, + WriteOperations, + ReadOperations, +} from '../authorization/rac_authorization'; +import { AuditLogger, EventOutcome } from '../../../security/server'; +// TODO: later +// import { alertAuditEvent, AlertAuditAction } from './audit_events'; +import { nodeBuilder } from '../../../../../src/plugins/data/common'; + +// export interface RegistryAlertTypeWithAuth extends RegistryAlertType { +// authorizedConsumers: string[]; +// } +// type NormalizedAlertAction = Omit; +export type CreateAPIKeyResult = + | { apiKeysEnabled: false } + | { apiKeysEnabled: true; result: SecurityPluginGrantAPIKeyResult }; +export type InvalidateAPIKeyResult = + | { apiKeysEnabled: false } + | { apiKeysEnabled: true; result: SecurityPluginInvalidateAPIKeyResult }; + +export interface ConstructorOptions { + logger: Logger; + authorization: RacAuthorization; + spaceId?: string; + kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; + auditLogger?: AuditLogger; + esClient: ElasticsearchClient; +} + +export interface FindOptions extends IndexType { + perPage?: number; + page?: number; + search?: string; + defaultSearchOperator?: 'AND' | 'OR'; + searchFields?: string[]; + sortField?: string; + sortOrder?: estypes.SortOrder; + hasReference?: { + type: string; + id: string; + }; + fields?: string[]; + filter?: string; +} + +export interface CreateAlertParams { + esClient: ElasticsearchClient; + owner: 'observability' | 'securitySolution'; +} + +interface IndexType { + [key: string]: unknown; +} + +export interface AggregateResult { + alertExecutionStatus: { [status: string]: number }; +} + +export interface FindResult { + page: number; + perPage: number; + total: number; + data: Array>; +} + +export interface UpdateOptions { + id: string; + data: { + status: string; + }; +} + +export interface GetAlertInstanceSummaryParams { + id: string; + dateStart?: string; +} + +export class RacClient { + private readonly logger: Logger; + private readonly spaceId?: string; + private readonly authorization: RacAuthorization; + private readonly kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version']; + private readonly auditLogger?: AuditLogger; + private readonly esClient: ElasticsearchClient; + + constructor({ + authorization, + logger, + spaceId, + kibanaVersion, + auditLogger, + esClient, + }: ConstructorOptions) { + this.logger = logger; + this.spaceId = spaceId; + this.authorization = authorization; + this.kibanaVersion = kibanaVersion; + this.auditLogger = auditLogger; + this.esClient = esClient; + } + + public async get({ + id, + owner, + }: { + id: string; + owner: typeof SERVER_APP_ID | 'observability'; + }): Promise { + // .get('alert', id); + try { + await this.authorization.ensureAuthorized( + // TODO: add spaceid here.. I think + // result.body._source?.owner, + owner, + ReadOperations.Get + ); + // TODO: type alert for the get method + + try { + const result = await this.esClient.get({ + index: '.siem-signals-devin-hurley-default', + id, + }); + console.error(`************\nRESULT ${JSON.stringify(result, null, 2)}\n************`); + return result; + } catch (exc) { + console.error('THREW ERROR WHEN TRYING GET', JSON.stringify(exc, null, 2)); + } + + // const result = await this.esClient.search({ + // index: '.siem*', + // body: { query: { match_all: {} } }, + // }); + } catch (error) { + console.error('HERES THE ERROR', error); + // this.auditLogger?.log( + // alertAuditEvent({ + // action: AlertAuditAction.GET, + // savedObject: { type: 'alert', id }, + // error, + // }) + // ); + throw error; + } + // this.auditLogger?.log( + // alertAuditEvent({ + // action: AlertAuditAction.GET, + // savedObject: { type: 'alert', id }, + // }) + // ); + // TODO: strip out owner field maybe? + // this.getAlertFromRaw(result.id, result.attributes, result.references); + + // return result; + + // return Promise.resolve({ id: 'hello world!!!' }); + // const result = await this.unsecuredSavedObjectsClient.get('alert', id); + // try { + // await this.authorization.ensureAuthorized( + // result.attributes.alertTypeId, + // result.attributes.consumer, + // ReadOperations.Get + // ); + // } catch (error) { + // this.auditLogger?.log( + // alertAuditEvent({ + // action: AlertAuditAction.GET, + // savedObject: { type: 'alert', id }, + // error, + // }) + // ); + // throw error; + // } + // this.auditLogger?.log( + // alertAuditEvent({ + // action: AlertAuditAction.GET, + // savedObject: { type: 'alert', id }, + // }) + // ); + // return this.getAlertFromRaw(result.id, result.attributes, result.references); + } + + public async find({ + owner, + }: { + owner: typeof SERVER_APP_ID | 'observability'; + }): Promise { + try { + await this.authorization.ensureAuthorized( + // TODO: add spaceid here.. I think + // result.body._source?.owner, + owner, + ReadOperations.Get + ); + // TODO: type alert for the get method + + try { + // const result = await this.esClient.get({ + // index: '.siem-signals-devin-hurley-default', + // id: 'ecf1d03a9f3456bb28bf3af5ef9fd2ef441641f3b495d92112e5e76d8feae62e', + // }); + const result = await this.esClient.search({ + index: '.siem-signals*', + body: { + query: { + term: { + 'signal.owner': { + value: owner, + }, + }, + }, + }, + }); + console.error(`************\nRESULT ${JSON.stringify(result, null, 2)}\n************`); + return result; + } catch (exc) { + console.error('THREW ERROR WHEN TRYING GET', JSON.stringify(exc, null, 2)); + } + + // const result = await this.esClient.search({ + // index: '.siem*', + // body: { query: { match_all: {} } }, + // }); + } catch (error) { + console.error('HERES THE ERROR', error); + // this.auditLogger?.log( + // alertAuditEvent({ + // action: AlertAuditAction.GET, + // savedObject: { type: 'alert', id }, + // error, + // }) + // ); + throw error; + } + // let authorizationTuple; + // try { + // authorizationTuple = await this.authorization.getFindAuthorizationFilter(); + // } catch (error) { + // this.auditLogger?.log( + // alertAuditEvent({ + // action: AlertAuditAction.FIND, + // error, + // }) + // ); + // throw error; + // } + // const { + // filter: authorizationFilter, + // ensureAlertTypeIsAuthorized, + // logSuccessfulAuthorization, + // } = authorizationTuple; + // const { + // page, + // per_page: perPage, + // total, + // saved_objects: data, + // } = await this.unsecuredSavedObjectsClient.find({ + // ...options, + // sortField: mapSortField(options.sortField), + // filter: + // (authorizationFilter && options.filter + // ? nodeBuilder.and([esKuery.fromKueryExpression(options.filter), authorizationFilter]) + // : authorizationFilter) ?? options.filter, + // fields: fields ? this.includeFieldsRequiredForAuthentication(fields) : fields, + // type: 'alert', + // }); + // const authorizedData = data.map(({ id, attributes, references }) => { + // try { + // ensureAlertTypeIsAuthorized(attributes.alertTypeId, attributes.consumer); + // } catch (error) { + // this.auditLogger?.log( + // alertAuditEvent({ + // action: AlertAuditAction.FIND, + // savedObject: { type: 'alert', id }, + // error, + // }) + // ); + // throw error; + // } + // return this.getAlertFromRaw( + // id, + // fields ? (pick(attributes, fields) as RawAlert) : attributes, + // references + // ); + // }); + // authorizedData.forEach(({ id }) => + // this.auditLogger?.log( + // alertAuditEvent({ + // action: AlertAuditAction.FIND, + // savedObject: { type: 'alert', id }, + // }) + // ) + // ); + // logSuccessfulAuthorization(); + // return { + // page, + // perPage, + // total, + // data: authorizedData, + // }; + } + + public async update({ + id, + data, + }: UpdateOptions): Promise> { + // return await retryIfConflicts( + // this.logger, + // `alertsClient.update('${id}')`, + // async () => await this.updateWithOCC({ id, data }) + // ); + } + + private async updateWithOCC({ + id, + data, + }: UpdateOptions): Promise> { + // let alertSavedObject: SavedObject; + // try { + // alertSavedObject = await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + // 'alert', + // id, + // { namespace: this.namespace } + // ); + // } catch (e) { + // // We'll skip invalidating the API key since we failed to load the decrypted saved object + // this.logger.error( + // `update(): Failed to load API key to invalidate on alert ${id}: ${e.message}` + // ); + // // Still attempt to load the object using SOC + // alertSavedObject = await this.unsecuredSavedObjectsClient.get('alert', id); + // } + // try { + // await this.authorization.ensureAuthorized( + // alertSavedObject.attributes.alertTypeId, + // alertSavedObject.attributes.consumer, + // WriteOperations.Update + // ); + // } catch (error) { + // this.auditLogger?.log( + // alertAuditEvent({ + // action: AlertAuditAction.UPDATE, + // savedObject: { type: 'alert', id }, + // error, + // }) + // ); + // throw error; + // } + // this.auditLogger?.log( + // alertAuditEvent({ + // action: AlertAuditAction.UPDATE, + // outcome: EventOutcome.UNKNOWN, + // savedObject: { type: 'alert', id }, + // }) + // ); + // this.alertTypeRegistry.ensureAlertTypeEnabled(alertSavedObject.attributes.alertTypeId); + // const updateResult = await this.updateAlert({ id, data }, alertSavedObject); + // await Promise.all([ + // alertSavedObject.attributes.apiKey + // ? markApiKeyForInvalidation( + // { apiKey: alertSavedObject.attributes.apiKey }, + // this.logger, + // this.unsecuredSavedObjectsClient + // ) + // : null, + // (async () => { + // if ( + // updateResult.scheduledTaskId && + // !isEqual(alertSavedObject.attributes.schedule, updateResult.schedule) + // ) { + // this.taskManager + // .runNow(updateResult.scheduledTaskId) + // .then(() => { + // this.logger.debug( + // `Alert update has rescheduled the underlying task: ${updateResult.scheduledTaskId}` + // ); + // }) + // .catch((err: Error) => { + // this.logger.error( + // `Alert update failed to run its underlying task. TaskManager runNow failed with Error: ${err.message}` + // ); + // }); + // } + // })(), + // ]); + // return updateResult; + } + + static async create({ esClient, owner, data }: createAlertParams) {} +} diff --git a/x-pack/plugins/rule_registry/server/rac_client/rac_client_factory.ts b/x-pack/plugins/rule_registry/server/rac_client/rac_client_factory.ts new file mode 100644 index 0000000000000..fc58cec77d685 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rac_client/rac_client_factory.ts @@ -0,0 +1,83 @@ +/* + * 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, + PluginInitializerContext, +} from 'src/core/server'; +import { RacClient } from './rac_client'; +import { SecurityPluginSetup, SecurityPluginStart } from '../../../security/server'; +import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; +// TODO: implement this class and audit logger +import { RacAuthorization } from '../authorization/rac_authorization'; +// import { AlertsAuthorizationAuditLogger } from './authorization/audit_logger'; +import { Space } from '../../../spaces/server'; +import { RacAuthorizationAuditLogger } from '../authorization/audit_logger'; + +export interface RacClientFactoryOpts { + logger: Logger; + securityPluginSetup?: SecurityPluginSetup; + securityPluginStart?: SecurityPluginStart; + getSpaceId: (request: KibanaRequest) => string | undefined; + getSpace: (request: KibanaRequest) => Promise; + features: FeaturesPluginStart; + kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; + esClient: ElasticsearchClient; +} + +export class RacClientFactory { + private isInitialized = false; + private logger!: Logger; + private securityPluginSetup?: SecurityPluginSetup; + private securityPluginStart?: SecurityPluginStart; + private getSpaceId!: (request: KibanaRequest) => string | undefined; + private getSpace!: (request: KibanaRequest) => Promise; + private features!: FeaturesPluginStart; + private kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version']; + private esClient!: ElasticsearchClient; + + public initialize(options: RacClientFactoryOpts) { + /** + * This should be called by the plugin's start() method. + */ + if (this.isInitialized) { + throw new Error('AlertsClientFactory already initialized'); + } + this.isInitialized = true; + this.logger = options.logger; + this.getSpaceId = options.getSpaceId; + this.features = options.features; + this.securityPluginSetup = options.securityPluginSetup; + this.securityPluginStart = options.securityPluginStart; + this.esClient = options.esClient; + this.getSpace = options.getSpace; + } + + public async create(request: KibanaRequest): Promise { + const { features, securityPluginSetup, securityPluginStart } = this; + const spaceId = this.getSpaceId(request); + + const authorization = await RacAuthorization.create({ + authorization: securityPluginStart?.authz, + request, + getSpace: this.getSpace, + features: features!, + isAuthEnabled: true, + auditLogger: new RacAuthorizationAuditLogger(securityPluginSetup?.audit.asScoped(request)), + }); + return new RacClient({ + spaceId, + kibanaVersion: this.kibanaVersion, + logger: this.logger, + authorization, + auditLogger: securityPluginSetup?.audit.asScoped(request), + esClient: this.esClient, + }); + } +} diff --git a/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/index.ts b/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/index.ts index 0d7735380b640..306e7ccbc7300 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/index.ts +++ b/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/index.ts @@ -17,6 +17,8 @@ import { TypeOfFieldMap } from '../../../common'; import { ScopedRuleRegistryClient, EventsOf } from './types'; import { BaseRuleFieldMap } from '../../../common'; import { RuleRegistry } from '..'; +import { SERVER_APP_ID } from '../../../../security_solution/server'; +import { APM_SERVER_FEATURE_ID } from '../../../../apm/server'; const createPathReporterError = (either: Either) => { const error = new Error(`Failed to validate alert event`); @@ -148,6 +150,7 @@ export function createScopedRuleRegistryClient { + logger.debug(`indexAliasName: ${indexAliasName}`); const validations = docs.map((doc) => { return fieldmapType.decode({ ...doc, diff --git a/x-pack/plugins/rule_registry/server/rule_registry/index.ts b/x-pack/plugins/rule_registry/server/rule_registry/index.ts index bbc381f60a809..a7d30337e3af2 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/index.ts +++ b/x-pack/plugins/rule_registry/server/rule_registry/index.ts @@ -53,6 +53,7 @@ export class RuleRegistry { private readonly types: Array> = []; private readonly fieldmapType: FieldMapType; + public readonly alertingPluginSetupContract: AlertingPluginSetupContract; constructor(private readonly options: RuleRegistryOptions) { const { logger, coreSetup } = options; @@ -60,7 +61,9 @@ export class RuleRegistry { this.fieldmapType = runtimeTypeFromFieldMap(options.fieldMap); const { wait, signal } = createReadySignal(); - + // Exposing alerting client temporarily to allow consumers to register rules + // both against the registry and alerting client directly + this.alertingPluginSetupContract = options.alertingPluginSetupContract; this.esAdapter = new ClusterClientAdapter<{ body: TypeOfFieldMap; index: string; diff --git a/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts index 65eaf0964cfca..06181a4955bbd 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts @@ -10,7 +10,7 @@ import v4 from 'uuid/v4'; import { Mutable } from 'utility-types'; import { AlertInstance } from '../../../../alerting/server'; import { ActionVariable, AlertInstanceState } from '../../../../alerting/common'; -import { RuleParams, RuleType } from '../../types'; +import { AlertAttributes, RuleParams, RuleType } from '../../types'; import { BaseRuleFieldMap, OutputOfFieldMap } from '../../../common'; import { PrepopulatedRuleEventFields } from '../create_scoped_rule_registry_client/types'; import { RuleRegistry } from '..'; @@ -68,6 +68,13 @@ export function createLifecycleRuleTypeFactory(): CreateLifecycleRuleType( + 'alert', + rule.uuid + ); + + logger.debug(`RULE REGISTRY CONSUMER ${so.attributes.consumer}`); + const decodedState = wrappedStateRt.decode(previousState); const state = isLeft(decodedState) @@ -150,6 +157,8 @@ export function createLifecycleRuleTypeFactory(): CreateLifecycleRuleType { const alertId = event['kibana.rac.alert.id']!; alertsDataMap[alertId] = event; @@ -169,6 +178,7 @@ export function createLifecycleRuleTypeFactory(): CreateLifecycleRuleType; +export interface AlertAttributes { + // actions: RuleAlertAction[]; + consumer: string; + enabled: boolean; + name: string; + tags: string[]; + createdBy: string; + createdAt: string; + updatedBy: string; + schedule: { + interval: string; + }; + throttle: string; + params: T; +} + type TypeOfRuleParams = TypeOf; type RuleExecutorServices< @@ -98,3 +117,20 @@ export type RuleType< export type FieldMapOf< TRuleRegistry extends RuleRegistry > = TRuleRegistry extends RuleRegistry ? TFieldMap : never; +/** + * @public + */ +export interface RacApiRequestHandlerContext { + getRacClient: () => Promise; +} + +/** + * @internal + */ +export interface RacRequestHandlerContext extends RequestHandlerContext { + ruleRegistry?: RacApiRequestHandlerContext; +} +/** + * @internal + */ +export type ContextProviderReturn = RacApiRequestHandlerContext; diff --git a/x-pack/plugins/security/server/authorization/actions/__snapshots__/rac.test.ts.snap b/x-pack/plugins/security/server/authorization/actions/__snapshots__/rac.test.ts.snap new file mode 100644 index 0000000000000..f53a0e03ac45e --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/__snapshots__/rac.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#get consumer of "" throws error 1`] = `"\\"owner\\" is required and must be a string"`; + +exports[`#get consumer of {} throws error 1`] = `"\\"owner\\" is required and must be a string"`; + +exports[`#get consumer of 1 throws error 1`] = `"\\"owner\\" is required and must be a string"`; + +exports[`#get consumer of null throws error 1`] = `"\\"owner\\" is required and must be a string"`; + +exports[`#get consumer of true throws error 1`] = `"\\"owner\\" is required and must be a string"`; + +exports[`#get consumer of undefined throws error 1`] = `"\\"owner\\" is required and must be a string"`; + +exports[`#get operation of "" throws error 1`] = `"\\"operation\\" is required and must be a string"`; + +exports[`#get operation of {} throws error 1`] = `"\\"operation\\" is required and must be a string"`; + +exports[`#get operation of 1 throws error 1`] = `"\\"operation\\" is required and must be a string"`; + +exports[`#get operation of null throws error 1`] = `"\\"operation\\" is required and must be a string"`; + +exports[`#get operation of true throws error 1`] = `"\\"operation\\" is required and must be a string"`; + +exports[`#get operation of undefined throws error 1`] = `"\\"operation\\" is required and must be a string"`; diff --git a/x-pack/plugins/security/server/authorization/actions/actions.mock.ts b/x-pack/plugins/security/server/authorization/actions/actions.mock.ts index 97890e21c0eb7..c6335e5862b59 100644 --- a/x-pack/plugins/security/server/authorization/actions/actions.mock.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.mock.ts @@ -9,6 +9,7 @@ import type { Actions } from './actions'; import { AlertingActions } from './alerting'; import { ApiActions } from './api'; import { AppActions } from './app'; +import { RacActions } from './rac'; import { SavedObjectActions } from './saved_object'; import { SpaceActions } from './space'; import { UIActions } from './ui'; @@ -19,6 +20,7 @@ jest.mock('./saved_object'); jest.mock('./space'); jest.mock('./ui'); jest.mock('./alerting'); +jest.mock('./rac'); const create = (versionNumber: string) => { const t = ({ @@ -30,6 +32,7 @@ const create = (versionNumber: string) => { space: new SpaceActions(versionNumber), ui: new UIActions(versionNumber), version: `version:${versionNumber}`, + rac: new RacActions(versionNumber), } as unknown) as jest.Mocked; return t; }; diff --git a/x-pack/plugins/security/server/authorization/actions/actions.ts b/x-pack/plugins/security/server/authorization/actions/actions.ts index 23d07f73f04be..5d4ae7a6eb671 100644 --- a/x-pack/plugins/security/server/authorization/actions/actions.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.ts @@ -8,6 +8,7 @@ import { AlertingActions } from './alerting'; import { ApiActions } from './api'; import { AppActions } from './app'; +import { RacActions } from './rac'; import { SavedObjectActions } from './saved_object'; import { SpaceActions } from './space'; import { UIActions } from './ui'; @@ -21,6 +22,8 @@ export class Actions { public readonly app = new AppActions(this.versionNumber); + public readonly rac = new RacActions(this.versionNumber); + public readonly login = 'login:'; public readonly savedObject = new SavedObjectActions(this.versionNumber); diff --git a/x-pack/plugins/security/server/authorization/actions/rac.test.ts b/x-pack/plugins/security/server/authorization/actions/rac.test.ts new file mode 100644 index 0000000000000..6ed67f32899a4 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/rac.test.ts @@ -0,0 +1,31 @@ +/* + * 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 { RacActions } from './rac'; + +const version = '1.0.0-zeta1'; + +describe('#get', () => { + [null, undefined, '', 1, true, {}].forEach((operation: any) => { + test(`operation of ${JSON.stringify(operation)} throws error`, () => { + const racActions = new RacActions(version); + expect(() => racActions.get('consumer', operation)).toThrowErrorMatchingSnapshot(); + }); + }); + + [null, '', 1, true, undefined, {}].forEach((consumer: any) => { + test(`consumer of ${JSON.stringify(consumer)} throws error`, () => { + const racActions = new RacActions(version); + expect(() => racActions.get(consumer, 'operation')).toThrowErrorMatchingSnapshot(); + }); + }); + + test('returns `rac:${owner}/${operation}`', () => { + const racActions = new RacActions(version); + expect(racActions.get('owner', 'bar-operation')).toBe('rac:1.0.0-zeta1:owner/bar-operation'); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/actions/rac.ts b/x-pack/plugins/security/server/authorization/actions/rac.ts new file mode 100644 index 0000000000000..e36d705acec70 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/rac.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 { isString } from 'lodash'; + +export class RacActions { + private readonly prefix: string; + + constructor(versionNumber: string) { + this.prefix = `rac:${versionNumber}`; + } + + public get(owner: string, operation: string): string { + if (!operation || !isString(operation)) { + throw new Error('"operation" is required and must be a string'); + } + + if (!owner || !isString(owner)) { + throw new Error('"owner" is required and must be a string'); + } + + return `${this.prefix}:${owner}/${operation}`; + } +} diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts index 21cf2421ce1b2..b76ed77a6a7ca 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts @@ -16,6 +16,7 @@ import { FeaturePrivilegeCatalogueBuilder } from './catalogue'; import { FeaturePrivilegeBuilder } from './feature_privilege_builder'; import { FeaturePrivilegeManagementBuilder } from './management'; import { FeaturePrivilegeNavlinkBuilder } from './navlink'; +import { FeaturePrivilegeRacBuilder } from './rac'; import { FeaturePrivilegeSavedObjectBuilder } from './saved_object'; import { FeaturePrivilegeUIBuilder } from './ui'; @@ -31,6 +32,7 @@ export const featurePrivilegeBuilderFactory = (actions: Actions): FeaturePrivile new FeaturePrivilegeSavedObjectBuilder(actions), new FeaturePrivilegeUIBuilder(actions), new FeaturePrivilegeAlertingBuilder(actions), + new FeaturePrivilegeRacBuilder(actions), ]; return { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/rac.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/rac.test.ts new file mode 100644 index 0000000000000..70c1fe1d30bab --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/rac.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { FeatureKibanaPrivileges } from '../../../../../features/server'; +import { Actions } from '../../actions'; +import { FeaturePrivilegeRacBuilder } from './rac'; + +const version = '1.0.0-zeta1'; + +describe(`rac`, () => { + describe(`feature_privilege_builder`, () => { + it('grants no privileges by default', () => { + const actions = new Actions(version); + const racFeaturePrivileges = new FeaturePrivilegeRacBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + expect(racFeaturePrivileges.getActions(privilege)).toEqual([]); + }); + + describe(`within feature`, () => { + it('grants `read` privileges under feature', () => { + const actions = new Actions(version); + const alertsFeaturePrivilege = new FeaturePrivilegeRacBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + rac: { + read: ['observability'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + expect(alertsFeaturePrivilege.getActions(privilege)).toMatchInlineSnapshot(` + Array [ + "rac:1.0.0-zeta1:observability/get", + "rac:1.0.0-zeta1:observability/find", + ] + `); + }); + + it('grants `all` privileges under feature', () => { + const actions = new Actions(version); + const alertsFeaturePrivilege = new FeaturePrivilegeRacBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + rac: { + all: ['security'], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + expect(alertsFeaturePrivilege.getActions(privilege)).toMatchInlineSnapshot(` + Array [ + "rac:1.0.0-zeta1:security/get", + "rac:1.0.0-zeta1:security/find", + "rac:1.0.0-zeta1:security/update", + ] + `); + }); + + it('grants both `all` and `read` privileges under feature', () => { + const actions = new Actions(version); + const alertsFeaturePrivilege = new FeaturePrivilegeRacBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + rac: { + all: ['security'], + read: ['obs'], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + expect(alertsFeaturePrivilege.getActions(privilege)).toMatchInlineSnapshot(` + Array [ + "rac:1.0.0-zeta1:security/get", + "rac:1.0.0-zeta1:security/find", + "rac:1.0.0-zeta1:security/update", + "rac:1.0.0-zeta1:obs/get", + "rac:1.0.0-zeta1:obs/find", + ] + `); + }); + + it('grants both `all` and `read` privileges under feature with multiple values in rac array', () => { + const actions = new Actions(version); + const alertsFeaturePrivilege = new FeaturePrivilegeRacBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + rac: { + all: ['security', 'other-security'], + read: ['obs', 'other-obs'], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + expect(alertsFeaturePrivilege.getActions(privilege)).toMatchInlineSnapshot(` + Array [ + "rac:1.0.0-zeta1:security/get", + "rac:1.0.0-zeta1:security/find", + "rac:1.0.0-zeta1:security/update", + "rac:1.0.0-zeta1:other-security/get", + "rac:1.0.0-zeta1:other-security/find", + "rac:1.0.0-zeta1:other-security/update", + "rac:1.0.0-zeta1:obs/get", + "rac:1.0.0-zeta1:obs/find", + "rac:1.0.0-zeta1:other-obs/get", + "rac:1.0.0-zeta1:other-obs/find", + ] + `); + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/rac.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/rac.ts new file mode 100644 index 0000000000000..326baae10dd3f --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/rac.ts @@ -0,0 +1,40 @@ +/* + * 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 { uniq } from 'lodash'; + +import type { FeatureKibanaPrivileges } from '../../../../../features/server'; +import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; + +// TODO: Sync with Larry/team to figure out best method of passing in +// and checking space id since we're not dealing with saved objects for alerts +// ie: mimicking spaces in data index +const spaceId: string = 'default'; +const readOperations: string[] = ['get', 'find']; +const writeOperations: string[] = ['update']; +const allOperations: string[] = [...readOperations, ...writeOperations]; + +export class FeaturePrivilegeRacBuilder extends BaseFeaturePrivilegeBuilder { + // TODO: Figure out if we need to pass in KibanaFeature id here + public getActions(privilegeDefinition: FeatureKibanaPrivileges): string[] { + /** + * Returns the operator type, may not need this if using io-ts types + * + * @param operations all CRUD operations to check privileges for + * @param owners plugin or feature registered with RAC whose rule generate alerts + */ + const getAlertingPrivilege = (operations: string[], owners: readonly string[]) => + owners.flatMap((owner) => + operations.map((operation) => this.actions.rac.get(owner, operation)) + ); + + return uniq([ + ...getAlertingPrivilege(allOperations, privilegeDefinition.rac?.all ?? []), + ...getAlertingPrivilege(readOperations, privilegeDefinition.rac?.read ?? []), + ]); + } +} diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index effefdd438c5c..2e9c47263f420 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -153,6 +153,11 @@ export const SIGNALS_ID = `siem.signals`; */ export const NOTIFICATIONS_ID = `siem.notifications`; +/** + * Id's for reference rule types + */ +export const REFERENCE_RULE_ALERT_TYPE_ID = `siem.referenceRule`; + /** * Special internal structure for tags for signals. This is used * to filter out tags that have internal structures within them. diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 50a5f62740271..02dbc56bd3397 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -8,6 +8,7 @@ "actions", "alerting", "cases", + "ruleRegistry", "data", "dataEnhanced", "embeddable", diff --git a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts index 53702bcf23b89..ed5fac1b1260a 100644 --- a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts @@ -82,14 +82,14 @@ export const getAllExistingIndexNamesSelector = () => { }; const EXCLUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*'; - +const ALERTS_AS_DATA_INDEX = '*alert-history-security-solution*'; export const getSourcererScopeSelector = () => { const getScopeIdSelector = scopeIdSelector(); const getSelectedPatterns = memoizeOne((selectedPatternsStr: string): string[] => { const selectedPatterns = selectedPatternsStr.length > 0 ? selectedPatternsStr.split(',') : []; return selectedPatterns.some((index) => index === 'logs-*') - ? [...selectedPatterns, EXCLUDE_ELASTIC_CLOUD_INDEX] - : selectedPatterns; + ? [...selectedPatterns, EXCLUDE_ELASTIC_CLOUD_INDEX, ALERTS_AS_DATA_INDEX] + : [...selectedPatterns, ALERTS_AS_DATA_INDEX]; }); const getIndexPattern = memoizeOne( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 478c8930b8dd3..fdcc33fcc0024 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -14,23 +14,23 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { columns } from '../../configurations/security_solution_detections/columns'; export const buildAlertStatusFilter = (status: Status): Filter[] => [ - { - meta: { - alias: null, - negate: false, - disabled: false, - type: 'phrase', - key: 'signal.status', - params: { - query: status, - }, - }, - query: { - term: { - 'signal.status': status, - }, - }, - }, + // { + // meta: { + // alias: null, + // negate: false, + // disabled: false, + // type: 'phrase', + // key: 'signal.status', + // params: { + // query: status, + // }, + // }, + // query: { + // term: { + // 'signal.status': status, + // }, + // }, + // }, ]; export const buildAlertsRuleIdFilter = (ruleId: string | null): Filter[] => @@ -92,35 +92,112 @@ export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean): ] : []; +export const alertsHeaders: ColumnHeaderOptions[] = [ + { + columnHeaderType: defaultColumnHeaderType, + id: '@timestamp', + width: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.rule.name', + label: i18n.ALERTS_HEADERS_RULE, + linkField: 'signal.rule.id', + width: DEFAULT_COLUMN_MIN_WIDTH, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.rule.version', + label: i18n.ALERTS_HEADERS_VERSION, + width: 95, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.rule.type', + label: i18n.ALERTS_HEADERS_METHOD, + width: 100, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.rule.severity', + label: i18n.ALERTS_HEADERS_SEVERITY, + width: 105, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.rule.risk_score', + label: i18n.ALERTS_HEADERS_RISK_SCORE, + width: 115, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'event.module', + linkField: 'rule.reference', + width: DEFAULT_COLUMN_MIN_WIDTH, + }, + { + category: 'event', + columnHeaderType: defaultColumnHeaderType, + id: 'event.action', + type: 'string', + aggregatable: true, + width: 140, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'event.category', + width: 150, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'host.name', + width: 120, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'user.name', + width: 120, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'source.ip', + width: 120, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'destination.ip', + width: 140, + }, +]; + +export const requiredFieldsForActions = [ + 'alert.id', + '@timestamp', + 'event.kind', + 'alert.start', + 'alert.uuid', + 'event.action', + 'alert.status', + 'alert.duration.us', + 'rule.uuid', + 'rule.id', + 'rule.name', + 'rule.category', + 'producer', + 'tags', +]; + +// export const alertsHeaders: ColumnHeaderOptions[] = requiredFieldsForActions.map( +// (field) => ({ +// columnHeaderType: defaultColumnHeaderType, +// id: field, +// width: 120, +// }) +// ); + export const alertsDefaultModel: SubsetTimelineModel = { ...timelineDefaults, columns, showCheckboxes: true, excludedRowRendererIds: Object.values(RowRendererId), }; - -export const requiredFieldsForActions = [ - '@timestamp', - 'signal.status', - 'signal.group.id', - 'signal.original_time', - 'signal.rule.building_block_type', - 'signal.rule.filters', - 'signal.rule.from', - 'signal.rule.language', - 'signal.rule.query', - 'signal.rule.name', - 'signal.rule.to', - 'signal.rule.id', - 'signal.rule.index', - 'signal.rule.type', - 'signal.original_event.kind', - 'signal.original_event.module', - // Endpoint exception fields - 'file.path', - 'file.Ext.code_signature.subject_name', - 'file.Ext.code_signature.trusted', - 'file.hash.sha256', - 'host.os.family', - 'event.code', -]; diff --git a/x-pack/plugins/security_solution/server/index.ts b/x-pack/plugins/security_solution/server/index.ts index a4b9dddec812e..ed31882e89573 100644 --- a/x-pack/plugins/security_solution/server/index.ts +++ b/x-pack/plugins/security_solution/server/index.ts @@ -8,7 +8,7 @@ import { PluginInitializerContext, PluginConfigDescriptor } from '../../../../src/core/server'; import { Plugin, PluginSetup, PluginStart } from './plugin'; import { configSchema, ConfigType } from './config'; -import { SIGNALS_INDEX_KEY } from '../common/constants'; +import { SIGNALS_INDEX_KEY, SERVER_APP_ID } from '../common/constants'; import { AppClient } from './types'; export const plugin = (context: PluginInitializerContext) => { @@ -46,7 +46,7 @@ export const config: PluginConfigDescriptor = { }; export { ConfigType, Plugin, PluginSetup, PluginStart }; -export { AppClient }; +export { AppClient, SERVER_APP_ID }; // Exports to be shared with plugins such as x-pack/lists plugin export { deleteTemplate } from './lib/detection_engine/index/delete_template'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/reference_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/reference_rule.ts new file mode 100644 index 0000000000000..4d9ed4feefe46 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/reference_rule.ts @@ -0,0 +1,59 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { v4 as uuidv4 } from 'uuid'; + +import { createLifecycleRuleTypeFactory } from '../../../../../rule_registry/server'; +import { REFERENCE_RULE_ALERT_TYPE_ID } from '../../../../common/constants'; +import { SecurityRuleRegistry } from '../../../plugin'; + +const createSecurityLifecycleRuleType = createLifecycleRuleTypeFactory(); + +export const referenceRuleAlertType = createSecurityLifecycleRuleType({ + id: REFERENCE_RULE_ALERT_TYPE_ID, + name: 'ReferenceRule alert type', + validate: { + params: schema.object({ + server: schema.string(), + threshold: schema.number({ min: 0, max: 1 }), + }), + }, + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + { + id: 'warning', + name: 'Warning', + }, + ], + defaultActionGroupId: 'default', + actionVariables: { + context: [ + { name: 'server', description: 'the server' }, + { + name: 'hasCpuUsageIncreased', + description: 'boolean indicating if the cpu usage has increased', + }, + ], + }, + minimumLicenseRequired: 'basic', + producer: 'security-solution', + async executor({ services, params }) { + services.alertWithLifecycle({ + id: `${uuidv4()}`, + fields: {}, + }); + + return { + lastChecked: new Date(), + }; + }, +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/scripts/create_reference_rule.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/scripts/create_reference_rule.sh new file mode 100644 index 0000000000000..193478419ffcb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/scripts/create_reference_rule.sh @@ -0,0 +1,33 @@ +#!/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. +# + +curl -X POST http://localhost:5601/${BASE_PATH}/api/alerts/alert \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -H 'kbn-xsrf: true' \ + -H 'Content-Type: application/json' \ + --verbose \ + -d ' +{ + "params":{ + "server":"howdy", + "threshold": 0.90 + }, + "consumer":"alerts", + "alertTypeId":"siem.referenceRule", + "schedule":{ + "interval":"1m" + }, + "actions":[], + "tags":[ + "cpu" + ], + "notifyWhen":"onActionGroupChange", + "name":"Oh hai world!" +}' + + diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json index d6a06848592cc..81c5c3b296ba8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json @@ -77,6 +77,9 @@ } } }, + "owner": { + "type": "keyword" + }, "rule": { "properties": { "id": { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts index 005266f5c178f..dc0d2842b823f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { SetupPlugins } from '../../../../plugin'; import { findRuleValidateTypeDependents } from '../../../../../common/detection_engine/schemas/request/find_rules_type_dependents'; import { findRulesSchema, @@ -19,7 +20,10 @@ import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_s import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; import { transformFindAlerts } from './utils'; -export const findRulesRoute = (router: SecuritySolutionPluginRouter) => { +export const findRulesRoute = ( + router: SecuritySolutionPluginRouter, + rulesRegistry: SetupPlugins['ruleRegistry'] +) => { router.get( { path: `${DETECTION_ENGINE_RULES_URL}/_find`, @@ -43,6 +47,16 @@ export const findRulesRoute = (router: SecuritySolutionPluginRouter) => { const { query } = request; const alertsClient = context.alerting?.getAlertsClient(); const savedObjectsClient = context.core.savedObjects.client; + const racClient = await context.ruleRegistry?.getRacClient(); + try { + const helloWorld = await racClient?.get({ + id: 'hello world!!!', + owner: 'securitySolution', + }); + console.error('RESPONSE FROM RAC CLIENT', helloWorld); + } catch (exc) { + console.error('SOMETHING THREW AN ERROR', JSON.stringify(exc, null, 2)); + } if (!alertsClient) { return siemResponse.error({ statusCode: 404 }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json index 07827069dbc73..f6360655efb83 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json @@ -23,7 +23,8 @@ "ml": ["read"], "siem": ["read"], "actions": ["read"], - "builtInAlerts": ["read"] + "builtInAlerts": ["read"], + "rac": ["all"] }, "spaces": ["*"] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index 10cc168700447..a82f0e05604a7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -18,6 +18,7 @@ import { import { buildRuleWithoutOverrides, buildRuleWithOverrides } from './build_rule'; import { additionalSignalFields, buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; +import { SERVER_APP_ID } from '../../../../common/constants'; import { EqlSequence } from '../../../../common/detection_engine/types'; import { generateSignalId, wrapBuildingBlocks, wrapSignal } from './utils'; @@ -28,7 +29,7 @@ export const buildBulkBody = ( ): SignalHit => { const rule = buildRuleWithOverrides(ruleSO, doc._source!); const signal: Signal = { - ...buildSignal([doc], rule), + ...buildSignal([doc], rule, ruleSO.attributes.consumer), ...additionalSignalFields(doc), }; const event = buildEventTypeSignal(doc); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts index 237536a99c0f0..57bd58ced1494 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts @@ -6,6 +6,7 @@ */ import { SearchTypes } from '../../../../common/detection_engine/types'; +import { SERVER_APP_ID } from '../../../../common/constants'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; import { SIGNALS_TEMPLATE_VERSION } from '../routes/index/get_signals_template'; import { isEventTypeSignal } from './build_event_type_signal'; @@ -76,7 +77,11 @@ export const removeClashes = (doc: BaseSignalHit): BaseSignalHit => { * @param docs The parent signals/events of the new signal to be built. * @param rule The rule that is generating the new signal. */ -export const buildSignal = (docs: BaseSignalHit[], rule: RulesSchema): Signal => { +export const buildSignal = ( + docs: BaseSignalHit[], + rule: RulesSchema, + owner: string // typeof SERVER_APP_ID +): Signal => { const _meta = { version: SIGNALS_TEMPLATE_VERSION, }; @@ -92,6 +97,7 @@ export const buildSignal = (docs: BaseSignalHit[], rule: RulesSchema): Signal => parents, ancestors, status: 'open', + owner, rule, depth, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts index 92d01fef6e50c..efaa242f9cf9a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts @@ -12,6 +12,8 @@ import { AlertInstanceState, AlertServices, } from '../../../../../alerting/server'; + +import { SERVER_APP_ID } from '../../../../common/constants'; import { AlertAttributes, SignalHit, SignalSearchResponse, WrappedSignalHit } from './types'; import { RefreshTypes } from '../types'; import { generateId, makeFloatString, errorAggregator } from './utils'; @@ -124,7 +126,7 @@ export const singleBulkCreate = async ({ buildBulkBody(ruleSO, doc), ]); const start = performance.now(); - const { body: response } = await services.scopedClusterClient.asCurrentUser.bulk({ + const { body: response } = await services.scopedClusterClient.asInternalUser.bulk({ index: signalsIndex, refresh, body: bulkBody, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 80d08a77ba5d2..3fe3311c565bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -211,6 +211,7 @@ export interface Signal { }; original_time?: string; original_event?: SearchTypes; + owner?: string; status: Status; threshold_result?: ThresholdResult; original_signal?: SearchTypes; @@ -226,6 +227,7 @@ export interface SignalHit { export interface AlertAttributes { actions: RuleAlertAction[]; + consumer: string; enabled: boolean; name: string; tags: string[]; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 158c2e94b2d7a..d80f87eaa41b3 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -27,7 +27,9 @@ import { PluginSetupContract as AlertingSetup, PluginStartContract as AlertPluginStartContract, } from '../../alerting/server'; -import { SecurityPluginSetup as SecuritySetup, SecurityPluginStart } from '../../security/server'; +import { ecsFieldMap, pickWithPatterns } from '../../rule_registry/common'; +import { RuleRegistryPluginSetupContract } from '../../rule_registry/server'; +import { SecurityPluginSetup as SecuritySetup } from '../../security/server'; import { PluginSetupContract as FeaturesSetup } from '../../features/server'; import { MlPluginSetup as MlSetup } from '../../ml/server'; import { ListPluginSetup } from '../../lists/server'; @@ -37,6 +39,7 @@ import { ILicense, LicensingPluginStart } from '../../licensing/server'; import { FleetStartContract } from '../../fleet/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import { compose } from './lib/compose/kibana'; +import { referenceRuleAlertType } from './lib/detection_engine/reference_rules/reference_rule'; import { initRoutes } from './routes'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; @@ -53,6 +56,7 @@ import { SecurityPageName, SIGNALS_ID, NOTIFICATIONS_ID, + REFERENCE_RULE_ALERT_TYPE_ID, } from '../common/constants'; import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; @@ -79,6 +83,8 @@ import { securitySolutionTimelineEqlSearchStrategyProvider } from './search_stra import { parseExperimentalConfigValue } from '../common/experimental_features'; import { migrateArtifactsToFleet } from './endpoint/lib/artifacts/migrate_artifacts_to_fleet'; +export type SecurityRuleRegistry = SetupPlugins['ruleRegistry']; + export interface SetupPlugins { alerting: AlertingSetup; data: DataPluginSetup; @@ -86,6 +92,7 @@ export interface SetupPlugins { features: FeaturesSetup; lists?: ListPluginSetup; ml?: MlSetup; + ruleRegistry: RuleRegistryPluginSetupContract; security?: SecuritySetup; spaces?: SpacesSetup; taskManager?: TaskManagerSetupContract; @@ -98,6 +105,7 @@ export interface StartPlugins { data: DataPluginStart; fleet?: FleetStartContract; licensing: LicensingPluginStart; + ruleRegistry: RuleRegistryPluginSetupContract; taskManager?: TaskManagerStartContract; telemetry?: TelemetryPluginStart; security: SecurityPluginStart; @@ -133,6 +141,7 @@ export class Plugin implements IPlugin, plugins: SetupPlugins) { this.logger.debug('plugin setup'); + this.setupPlugins = plugins; const config = this.config; const globalConfig = this.context.config.legacy.get(); @@ -199,7 +209,8 @@ export class Plugin implements IPlugin { + try { + const racClient = await context.ruleRegistry?.getRacClient(); + const thing = await racClient?.find({ owner: SERVER_APP_ID }); + console.error('hits?', JSON.stringify(thing.body.hits.hits, null, 2)); + return res.ok({ body: { success: true, alerts: thing.body.hits.hits } }); + } catch (err) { + console.error('monitoring route threw an error'); + console.error('ERROR JSON', JSON.stringify(err, null, 2)); + const statusCode = err.output.statusCode; + console.error('ERROR STATUSCODE?', statusCode); + // { message: err.message }, + + // const contentType = { + // 'Content-Type': 'application/json', + // }; + // const defaultedHeaders = { + // ...contentType, + // }; + + // return res.custom({ + // statusCode, + // headers: defaultedHeaders, + // body: Buffer.from( + // JSON.stringify({ + // message: 'hello world', //err.message, + // status_code: statusCode, + // }) + // ), + // }); + return res.unauthorized({ body: { message: err.message } }); + } + }); + + const referenceRuleTypes = [REFERENCE_RULE_ALERT_TYPE_ID]; + const ruleTypes = [SIGNALS_ID, NOTIFICATIONS_ID, ...referenceRuleTypes]; + + plugins.features.registerKibanaFeature({ + id: 'rac', + name: 'RAC', + order: 1100, + app: [...securitySubPlugins, 'kibana'], + category: DEFAULT_APP_CATEGORIES.security, + rac: [SERVER_APP_ID], + privileges: { + all: { + app: [...securitySubPlugins, 'kibana'], + rac: { + all: [SERVER_APP_ID], + }, + savedObject: { + all: [ + 'alert', + ...caseSavedObjects, + 'exception-list', + 'exception-list-agnostic', + ...savedObjectTypes, + ], + read: ['config'], + }, + ui: ['show', 'crud'], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['show'], + }, + }, + }); plugins.features.registerKibanaFeature({ id: SERVER_APP_ID, @@ -220,9 +299,13 @@ export class Plugin implements IPlugin { // Detection Engine Rule routes that have the REST endpoints of /api/detection_engine/rules // All REST rule creation, deletion, updating, etc...... @@ -68,7 +69,7 @@ export const initRoutes = ( updateRulesRoute(router, ml); patchRulesRoute(router, ml); deleteRulesRoute(router); - findRulesRoute(router); + findRulesRoute(router, ruleRegistry); addPrepackedRulesRoute(router, config, security); getPrepackagedRulesStatusRoute(router, config, security); diff --git a/x-pack/plugins/security_solution/server/types.ts b/x-pack/plugins/security_solution/server/types.ts index ea63e188ab26d..efab6faf21fb0 100644 --- a/x-pack/plugins/security_solution/server/types.ts +++ b/x-pack/plugins/security_solution/server/types.ts @@ -6,6 +6,7 @@ */ import type { IRouter, RequestHandlerContext } from 'src/core/server'; +import { RacApiRequestHandlerContext } from '../../rule_registry/server'; import type { ListsApiRequestHandlerContext } from '../../lists/server'; import type { LicensingApiRequestHandlerContext } from '../../licensing/server'; import type { AlertingApiRequestHandlerContext } from '../../alerting/server'; @@ -23,6 +24,7 @@ export type SecuritySolutionRequestHandlerContext = RequestHandlerContext & { licensing: LicensingApiRequestHandlerContext; alerting: AlertingApiRequestHandlerContext; lists?: ListsApiRequestHandlerContext; + ruleRegistry?: RacApiRequestHandlerContext; }; export type SecuritySolutionPluginRouter = IRouter;