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..ede1c410d9cb2 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(), }); @@ -70,6 +75,10 @@ const kibanaIndependentSubFeaturePrivilegeSchema = Joi.object({ all: alertingSchema, read: alertingSchema, }), + rac: Joi.object({ + all: racSchema, + read: racSchema, + }), api: Joi.array().items(Joi.string()), app: Joi.array().items(Joi.string()), savedObject: Joi.object({ @@ -113,6 +122,7 @@ const kibanaFeatureSchema = Joi.object({ management: managementSchema, catalogue: catalogueSchema, alerting: alertingSchema, + rac: racSchema, privileges: Joi.object({ all: kibanaPrivilegeSchema, read: kibanaPrivilegeSchema, @@ -161,7 +171,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 +186,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 +231,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 +309,8 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { validateManagementEntry(privilegeId, privilegeDefinition.management); validateAlertingEntry(privilegeId, privilegeDefinition.alerting); + + validateRacEntry(privilegeId, privilegeDefinition.rac); }); const subFeatureEntries = feature.subFeatures ?? []; @@ -290,6 +321,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); }); }); }); @@ -340,6 +372,16 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { ).join(',')}` ); } + + if (unseenRacTypes.size > 0) { + throw new Error( + `Feature ${ + feature.id + } specifies rac entries which are not granted to any privileges: ${Array.from( + unseenRacTypes.values() + ).join(',')}` + ); + } } export function validateElasticsearchFeature(feature: ElasticsearchFeatureConfig) { 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 87134c765fbf9..627d5e7fcdc59 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -176,6 +176,18 @@ 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?.get({ id: 'hello world', owner: 'observability' }); + console.error('THE THING!!!', JSON.stringify(thing.body, null, 2)); + return res.ok({ body: { success: true } }); + } catch (err) { + console.error('monitoring route threw an error'); + console.error(err); + return res.notFound({ body: { message: err.message } }); + } + }); } return { @@ -244,8 +256,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 de3d044ccabcb..6191dcd8c35e9 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, @@ -57,6 +58,7 @@ export interface PluginsSetup { export interface RequestHandlerContextMonitoringPlugin extends RequestHandlerContext { actions?: ActionsApiRequestHandlerContext; alerting?: AlertingApiRequestHandlerContext; + ruleRegistry?: RacApiRequestHandlerContext; } export interface PluginsStart { diff --git a/x-pack/plugins/rule_registry/README.md b/x-pack/plugins/rule_registry/README.md index 17fe2b20f74fa..eca6a93ae8806 100644 --- a/x-pack/plugins/rule_registry/README.md +++ b/x-pack/plugins/rule_registry/README.md @@ -1,3 +1,19 @@ +# 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. @@ -66,3 +82,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/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..329344c635abe --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/rac_authorization.ts @@ -0,0 +1,226 @@ +/* + * 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 { RacAuthorizationAuditLogger } from './audit_logger'; +import { getEnabledKibanaSpaceFeatures } from './utils'; +import { GetSpaceFn, ReadOperations, WriteOperations, ESFilter } from './types'; + +export interface ConstructorOptions { + request: KibanaRequest; + authorization?: SecurityPluginStart['authz']; + owners: Set; + auditLogger: RacAuthorizationAuditLogger; +} + +export interface CreateOptions { + request: KibanaRequest; + authorization?: SecurityPluginStart['authz']; + 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; + + constructor({ request, authorization, owners, auditLogger }: ConstructorOptions) { + this.request = request; + this.authorization = authorization; + this.featureOwners = owners; + this.auditLogger = auditLogger; + } + + static async create({ + request, + authorization, + getSpace, + features, + auditLogger, + }: CreateOptions): Promise { + const owners = await getEnabledKibanaSpaceFeatures({ + getSpace, + request, + features, + }); + + return new RacAuthorization({ request, authorization, owners, 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); + if (authorization != null && this.shouldCheckAuthorization()) { + const requiredPrivileges = [authorization.actions.rac.get(owner, operation)]; + 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?: ESFilter; + ensureOwnerIsAuthorized: (owner: string) => void; + logSuccessfulAuthorization: () => void; + }> { + const operations = [ReadOperations.Find]; + + if (this.authorization != null && this.shouldCheckAuthorization()) { + const { username, authorizedOwners } = await this.getAuthorizedOwners(operations); + + if (!authorizedOwners.length) { + throw Boom.forbidden( + this.auditLogger.racAuthorizationFailure({ + username: username ?? '', + owner: Array.from(this.featureOwners).join(','), + operation: ReadOperations.Find, + type: EventType.ACCESS, + }) + ); + } + + return { + filter: getQueryFilter(authorizedOwners), + ensureOwnerIsAuthorized: (owner: string) => { + if (!authorizedOwners.includes(owner)) { + throw Boom.forbidden( + this.auditLogger.racAuthorizationFailure({ + username: username ?? '', + owner, + operation: ReadOperations.Find, + type: EventType.ACCESS, + }) + ); + } + }, + logSuccessfulAuthorization: () => { + this.auditLogger.racAuthorizationBulkSuccess({ + username, + owners: Array.from(this.featureOwners), + operation: ReadOperations.Find, + type: EventType.ACCESS, + }); + }, + }; + } + return { + ensureOwnerIsAuthorized: (owner: string) => {}, + logSuccessfulAuthorization: () => {}, + }; + } + + private async getAuthorizedOwners( + operations: Array + ): Promise<{ + username?: string; + hasAllRequested: boolean; + authorizedOwners: string[]; + }> { + const { featureOwners } = this; + if (this.authorization != null && this.shouldCheckAuthorization()) { + const checkPrivileges = this.authorization.checkPrivilegesDynamicallyWithRequest( + this.request + ); + const requiredPrivileges = getRequiredPrivileges( + featureOwners, + operations, + this.authorization.actions + ); + + const { hasAllRequested, username, privileges } = await checkPrivileges({ + kibana: [...requiredPrivileges.keys()], + }); + + const authorizedOwners = getAuthorizedOwners( + hasAllRequested, + featureOwners, + privileges, + requiredPrivileges + ); + + return { + hasAllRequested, + username, + authorizedOwners, + }; + } else { + return { + hasAllRequested: true, + authorizedOwners: Array.from(featureOwners), + }; + } + } +} diff --git a/x-pack/plugins/rule_registry/server/authorization/types.ts b/x-pack/plugins/rule_registry/server/authorization/types.ts new file mode 100644 index 0000000000000..a4820c35b858c --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/types.ts @@ -0,0 +1,25 @@ +/* + * 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 'src/core/server'; + +import { Space } from '../../../spaces/server'; + +export type GetSpaceFn = (request: KibanaRequest) => Promise; + +export enum ReadOperations { + Get = 'get', + Find = 'find', +} + +export enum WriteOperations { + Update = 'update', +} + +export interface ESFilter { + bool: unknown | unknown[]; +} 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..b97ff0a8e2c53 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/utils.test.ts @@ -0,0 +1,305 @@ +/* + * 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 { getAuthorizedOwners, getEnabledKibanaSpaceFeatures, getRequiredPrivileges } from './utils'; +import { ReadOperations, WriteOperations } from './types'; +import { RacActions } from '../../../security/server/authorization/actions/rac'; +import { CheckPrivilegesResponse } from '../../../security/server/authorization/types'; +import { Actions } from '../../../security/server/authorization'; + +const mockActions = ({ + rac: new RacActions('1.0.0'), +} as unknown) as Actions; + +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); + }); + }); + + describe('#getRequiredPrivileges', () => { + const owners = new Set(['securitySolution', 'observability']); + + test('it correctly maps required find privileges', () => { + const requiredPrivileges = getRequiredPrivileges(owners, [ReadOperations.Find], mockActions); + const resultingMap = new Map(); + resultingMap.set('rac:1.0.0:securitySolution/find', ['securitySolution']); + resultingMap.set('rac:1.0.0:observability/find', ['observability']); + + expect(requiredPrivileges).toEqual(resultingMap); + }); + + test('it correctly maps required get privileges', () => { + const requiredPrivileges = getRequiredPrivileges(owners, [ReadOperations.Get], mockActions); + const resultingMap = new Map(); + resultingMap.set('rac:1.0.0:securitySolution/get', ['securitySolution']); + resultingMap.set('rac:1.0.0:observability/get', ['observability']); + + expect(requiredPrivileges).toEqual(resultingMap); + }); + + test('it correctly maps required update privileges', () => { + const requiredPrivileges = getRequiredPrivileges( + owners, + [WriteOperations.Update], + mockActions + ); + const resultingMap = new Map(); + resultingMap.set('rac:1.0.0:securitySolution/update', ['securitySolution']); + resultingMap.set('rac:1.0.0:observability/update', ['observability']); + + expect(requiredPrivileges).toEqual(resultingMap); + }); + + // Added to show that this method isn't meant to validate action URIs, + // the security endpoint will do that for us - the action is simply + // splicing the info you give it together + test('it correctly maps any arbitrary operation', () => { + const requiredPrivileges = getRequiredPrivileges( + owners, + ['blah' as WriteOperations], + mockActions + ); + const resultingMap = new Map(); + resultingMap.set('rac:1.0.0:securitySolution/blah', ['securitySolution']); + resultingMap.set('rac:1.0.0:observability/blah', ['observability']); + + expect(requiredPrivileges).toEqual(resultingMap); + }); + }); + + describe('#getAuthorizedOwners', () => { + const owners = ['securitySolution', 'observability']; + const ownersSet = new Set(owners); + const privileges = ({ + kibana: [ + { privilege: 'rac:1.0.0:securitySolution/find', authorized: true }, + { privilege: 'rac:1.0.0:securitySolution/update', authorized: true }, + { privilege: 'rac:1.0.0:observability/update', authorized: true }, + ], + } as unknown) as CheckPrivilegesResponse['privileges']; + const requiredPrivileges = new Map(); + requiredPrivileges.set('rac:1.0.0:securitySolution/update', ['securitySolution']); + + test('it returns all owners if hasAllRequested is true', () => { + const result = getAuthorizedOwners(true, ownersSet, privileges, requiredPrivileges); + + expect(result).toEqual(owners); + }); + + test('it returns all owners where fetched privileges match required privileges', () => { + const result = getAuthorizedOwners(false, ownersSet, privileges, requiredPrivileges); + const authorizedOwners = ['securitySolution']; + + expect(result).toEqual(authorizedOwners); + }); + + test('it returns multiple owners if multiple privileges match required privileges', () => { + const multiOwnerRequiredPrivileges = new Map(); + multiOwnerRequiredPrivileges.set('rac:1.0.0:securitySolution/update', ['securitySolution']); + multiOwnerRequiredPrivileges.set('rac:1.0.0:observability/update', ['observability']); + + const result = getAuthorizedOwners( + false, + ownersSet, + privileges, + multiOwnerRequiredPrivileges + ); + const expectedResult = ['securitySolution', 'observability']; + + expect(result).toEqual(expectedResult); + }); + + test('it does not return owner if fetched privileges return authorized false', () => { + const privilegesMixedAuthorized = ({ + kibana: [ + { privilege: 'rac:1.0.0:securitySolution/find', authorized: false }, + { privilege: 'rac:1.0.0:securitySolution/update', authorized: false }, + { privilege: 'rac:1.0.0:observability/update', authorized: true }, + ], + } as unknown) as CheckPrivilegesResponse['privileges']; + const multiOwnerRequiredPrivileges = new Map(); + multiOwnerRequiredPrivileges.set('rac:1.0.0:securitySolution/update', ['securitySolution']); + multiOwnerRequiredPrivileges.set('rac:1.0.0:observability/update', ['observability']); + + const result = getAuthorizedOwners( + false, + ownersSet, + privilegesMixedAuthorized, + multiOwnerRequiredPrivileges + ); + const expectedResult = ['observability']; + + expect(result).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..f613d3d286699 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/authorization/utils.ts @@ -0,0 +1,147 @@ +/* + * 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 } from 'lodash'; + +import { KibanaRequest } from '../../../../../src/core/server'; +import { esQuery } from '../../../../../src/plugins/data/server'; +import { EsQueryConfig, Query } from '../../../../../src/plugins/data/common'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { CheckPrivilegesResponse } from '../../../security/server/authorization/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Actions } from '../../../security/server/authorization'; +import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; +import { ESFilter, GetSpaceFn, ReadOperations, WriteOperations } from './types'; + +/** + * Returns a user's enabled kibana features per the space the + * request cam from + * + * @param getSpace function that extracts space from request + * @param request + * @param features helper to get all possible kibana features + */ +export const getEnabledKibanaSpaceFeatures = async ({ + getSpace, + request, + features, +}: { + request: KibanaRequest; + getSpace: GetSpaceFn; + features: FeaturesPluginStart; +}): Promise> => { + try { + const disabledUserSpaceFeatures = new Set((await getSpace(request))?.disabledFeatures ?? []); + // Filter through all user Kibana features to find corresponding enabled + // RAC feature owners like 'security-solution' or 'observability' + const owners: Set = await new Set( + features + .getKibanaFeatures() + // get all the rac 'owners' that aren't disabled + .filter(({ id }) => !disabledUserSpaceFeatures.has(id)) + .flatMap((feature) => feature.rac ?? []) + ); + return owners; + } catch (error) { + return new Set(); + } +}; + +/** + * Returns map of URI to owner + * ex: Map {"rac:1.0.0:securitySolution/find" => ["securitySolution"]} + * + * @param owners set of owners user has access to + * @param operations array of operations user is attempting + * @param actions security plugin helper that builds URIs + */ +export const getRequiredPrivileges = ( + owners: Set, + operations: Array, + actions: Actions +): Map => { + const requiredPrivileges = new Map(); + + for (const owner of owners) { + for (const operation of operations) { + const actionUriFromSecurityPlugin = actions.rac.get(owner, operation); + requiredPrivileges.set(actionUriFromSecurityPlugin, [owner]); + } + } + + return requiredPrivileges; +}; + +/** + * Returns user's authorized owners after comparing what is being + * requested to what privileges we have marked down + * ex: ["securitySolution"] + * + * @param hasAllRequested boolean describing if user has all + * access to all requested owners/operations + * @param owners set of owners user has access to + * @param privileges privileges structure fetched from features plugin + * @param requiredPrivileges map of possible URI's to owners user is + * attempting to access + */ +export const getAuthorizedOwners = ( + hasAllRequested: boolean, + owners: Set, + privileges: CheckPrivilegesResponse['privileges'], + requiredPrivileges: Map +): string[] => { + return hasAllRequested + ? Array.from(owners) + : privileges.kibana.reduce((authorizedOwners, { authorized, privilege }) => { + if (authorized && requiredPrivileges.has(privilege)) { + const [owner] = requiredPrivileges.get(privilege)!; + return [...authorizedOwners, owner]; + } + + return authorizedOwners; + }, []); +}; + +export const getQueryFilter = (owners: string[]): ESFilter => { + const ownersFilter = getOwnersFilter(owners); + const kqlQuery: Query = { + language: 'kuery', + query: `(${ownersFilter})`, + }; + const config: EsQueryConfig = { + allowLeadingWildcards: true, + dateFormatTZ: 'Zulu', + ignoreFilterIfFieldNotInIndex: false, + queryStringOptions: { analyze_wildcard: true }, + }; + + return esQuery.buildEsQuery(undefined, kqlQuery, [], config); +}; + +export const getOwnersFilter = (owners: string[]): string => { + return owners + .reduce((query, owner) => { + ensureFieldIsSafeForQuery('owner', owner); + return [...query, `owner: ${owner}`]; + }, []) + .join(' or '); +}; + +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; +}; diff --git a/x-pack/plugins/rule_registry/server/index.ts b/x-pack/plugins/rule_registry/server/index.ts index 3d492bb690b05..2dfbdef44685d 100644 --- a/x-pack/plugins/rule_registry/server/index.ts +++ b/x-pack/plugins/rule_registry/server/index.ts @@ -9,10 +9,11 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { PluginInitializerContext } from 'src/core/server'; import { RuleRegistryPlugin } from './plugin'; -export { RuleRegistryPluginSetupContract } from './plugin'; +export { RacPluginSetupContract } 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 dabedc2849d07..9cf8b665dbb7c 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..56172d7964a9a --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rac_client/rac_client.ts @@ -0,0 +1,340 @@ +/* + * 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 { + 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; +} + +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: 'securitySolution' | 'observability'; + }): Promise { + // TODO: type alert for the get method + const result = await this.esClient.search({ + index: '.siem*', + body: { query: { match_all: {} } }, + }); + console.error(`************\nRESULT ${JSON.stringify(result, null, 2)}\n************`); + // .get('alert', id); + try { + await this.authorization.ensureAuthorized( + // TODO: add spaceid here.. I think + // result.body._source?.owner, + owner, + 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 }, + // }) + // ); + // 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({ + options: { fields, ...options } = {}, + }: { options?: FindOptions } = {}): Promise> { + // 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; + } +} 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/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/scripts/README.md b/x-pack/plugins/rule_registry/server/scripts/README.md new file mode 100644 index 0000000000000..e8b1a94f6d292 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/README.md @@ -0,0 +1,24 @@ +Users with roles granting them access to monitoring (observability) and siem (security solution) should only be able to access alerts with those roles + +```bash +myterminal~$ ./get_security_solution_alert.sh observer +{ + "statusCode": 404, + "error": "Not Found", + "message": "Unauthorized to get \"rac:8.0.0:securitySolution/get\" alert\"" +} +myterminal~$ ./get_security_solution_alert.sh +{ + "success": true +} +myterminal~$ ./get_observability_alert.sh +{ + "success": true +} +myterminal~$ ./get_observability_alert.sh hunter +{ + "statusCode": 404, + "error": "Not Found", + "message": "Unauthorized to get \"rac:8.0.0:observability/get\" alert\"" +} +``` \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/scripts/get_observability_alert.sh b/x-pack/plugins/rule_registry/server/scripts/get_observability_alert.sh new file mode 100755 index 0000000000000..e7845baceab06 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/get_observability_alert.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +set -e + +USER=${1:-'observer'} + +# Example: ./find_rules.sh +curl -s -k \ + -u $USER:changeme \ + -X GET ${KIBANA_URL}${SPACE_URL}/monitoring-myfakepath | jq . diff --git a/x-pack/plugins/rule_registry/server/scripts/get_security_solution_alert.sh b/x-pack/plugins/rule_registry/server/scripts/get_security_solution_alert.sh new file mode 100755 index 0000000000000..36a2acbd3e910 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/get_security_solution_alert.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +set -e + +USER=${1:-'hunter'} + +# Example: ./find_rules.sh +curl -s -k \ + -u $USER:changeme \ + -X GET ${KIBANA_URL}${SPACE_URL}/security-myfakepath | jq . diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/README.md b/x-pack/plugins/rule_registry/server/scripts/hunter/README.md new file mode 100644 index 0000000000000..a0269d5b060a3 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/README.md @@ -0,0 +1,5 @@ +This user can access the monitoring route at http://localhost:5601/security-myfakepath + +| Role | Data Sources | Security Solution ML Jobs/Results | Lists | Rules/Exceptions | Action Connectors | Signals/Alerts | +| :-----------------: | :----------: | :-------------------------------: | :---: | :--------------: | :---------------: | :------------: | +| Hunter / T3 Analyst | read, write | read | read | read, write | read | read, write | diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/delete_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/delete_detections_user.sh new file mode 100755 index 0000000000000..595f0a49282d8 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/delete_detections_user.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/hunter diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/detections_role.json b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_role.json new file mode 100644 index 0000000000000..119fe5421c86c --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_role.json @@ -0,0 +1,38 @@ +{ + "elasticsearch": { + "cluster": [], + "indices": [ + { + "names": [ + "apm-*-transaction*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*" + ], + "privileges": ["read", "write"] + }, + { + "names": [".siem-signals-*"], + "privileges": ["read", "write"] + }, + { + "names": [".lists*", ".items*"], + "privileges": ["read", "write"] + } + ] + }, + "kibana": [ + { + "feature": { + "ml": ["read"], + "siem": ["all"], + "actions": ["read"], + "builtInAlerts": ["all"] + }, + "spaces": ["*"] + } + ] +} diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/detections_user.json b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_user.json new file mode 100644 index 0000000000000..f9454cc0ad2fe --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["hunter"], + "full_name": "Hunter", + "email": "detections-reader@example.com" +} diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/get_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/get_detections_role.sh new file mode 100755 index 0000000000000..7ec850ce220bb --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/get_detections_role.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/hunter | jq -S . diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/index.ts b/x-pack/plugins/rule_registry/server/scripts/hunter/index.ts new file mode 100644 index 0000000000000..3411589de7721 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as hunterUser from './detections_user.json'; +import * as hunterRole from './detections_role.json'; +export { hunterUser, hunterRole }; diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_role.sh new file mode 100755 index 0000000000000..debffe0fcac4c --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_role.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +ROLE=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/hunter \ +-d @${ROLE} diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_user.sh new file mode 100755 index 0000000000000..ab2a053081394 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_user.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/hunter \ +-d @${USER} diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/README.md b/x-pack/plugins/rule_registry/server/scripts/observer/README.md new file mode 100644 index 0000000000000..4ddc601708c13 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/README.md @@ -0,0 +1,5 @@ +This user can access the monitoring route at http://localhost:5601/monitoring-myfakepath + +| Role | Data Sources | Security Solution ML Jobs/Results | Lists | Rules/Exceptions | Action Connectors | Signals/Alerts | +| :------: | :----------: | :-------------------------------: | :---: | :--------------: | :---------------: | :------------: | +| observer | read, write | read | read | read, write | read | read, write | diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/delete_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/observer/delete_detections_user.sh new file mode 100755 index 0000000000000..017d8904a51e1 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/delete_detections_user.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/observer diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/detections_role.json b/x-pack/plugins/rule_registry/server/scripts/observer/detections_role.json new file mode 100644 index 0000000000000..b981aafdfb676 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/detections_role.json @@ -0,0 +1,38 @@ +{ + "elasticsearch": { + "cluster": [], + "indices": [ + { + "names": [ + "apm-*-transaction*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*" + ], + "privileges": ["read", "write"] + }, + { + "names": [".siem-signals-*"], + "privileges": ["read", "write"] + }, + { + "names": [".lists*", ".items*"], + "privileges": ["read", "write"] + } + ] + }, + "kibana": [ + { + "feature": { + "ml": ["read"], + "monitoring": ["all"], + "actions": ["read"], + "builtInAlerts": ["all"] + }, + "spaces": ["*"] + } + ] +} diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/detections_user.json b/x-pack/plugins/rule_registry/server/scripts/observer/detections_user.json new file mode 100644 index 0000000000000..5759bad40f201 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/detections_user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["observer"], + "full_name": "Observer", + "email": "monitoring-observer@example.com" +} diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/get_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/observer/get_detections_role.sh new file mode 100755 index 0000000000000..7ec850ce220bb --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/get_detections_role.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/hunter | jq -S . diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/index.ts b/x-pack/plugins/rule_registry/server/scripts/observer/index.ts new file mode 100644 index 0000000000000..5feebc1caeed1 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as observerUser from './detections_user.json'; +import * as observerRole from './detections_role.json'; +export { observerUser, observerRole }; diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_role.sh new file mode 100755 index 0000000000000..f9a15aa32036c --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_role.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +ROLE=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/observer \ +-d @${ROLE} diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_user.sh new file mode 100755 index 0000000000000..f53ffd63f2868 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_user.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/observer \ +-d @${USER} diff --git a/x-pack/plugins/rule_registry/server/types.ts b/x-pack/plugins/rule_registry/server/types.ts index dd54046365d98..2a2add74ec285 100644 --- a/x-pack/plugins/rule_registry/server/types.ts +++ b/x-pack/plugins/rule_registry/server/types.ts @@ -5,7 +5,7 @@ * 2.0. */ import { Type, TypeOf } from '@kbn/config-schema'; -import { Logger } from 'kibana/server'; +import { Logger, RequestHandlerContext } from 'kibana/server'; import { ActionVariable, AlertInstanceContext, @@ -15,6 +15,7 @@ import { } from '../../alerting/common'; import { ActionGroup, AlertExecutorOptions } from '../../alerting/server'; import { RuleRegistry } from './rule_registry'; +import { RacClient } from './rac_client/rac_client'; import { ScopedRuleRegistryClient } from './rule_registry/create_scoped_rule_registry_client/types'; import { BaseRuleFieldMap } from '../common'; @@ -98,3 +99,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 4c62179f9ed54..348c0a795c96e 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -114,6 +114,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 d4551f76ae390..eef95d8d3a6ee 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -7,6 +7,7 @@ "requiredPlugins": [ "actions", "alerting", + "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 6a83039bf1ec8..06c45a48ab5fb 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 @@ -20,23 +20,23 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import * as i18n from './translations'; 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[] => @@ -176,35 +176,34 @@ export const alertsHeaders: ColumnHeaderOptions[] = [ }, ]; +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: alertsHeaders, 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/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/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/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 003ba4c8cf190..3497041e776e5 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -36,7 +36,6 @@ import { SpacesPluginSetup as SpacesSetup } from '../../spaces/server'; import { ILicense, LicensingPluginStart } from '../../licensing/server'; import { FleetStartContract } from '../../fleet/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; -import { initServer } from './init_server'; import { compose } from './lib/compose/kibana'; import { initRoutes } from './routes'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; @@ -59,6 +58,7 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; +import { registerHostIsolationRoutes } from './endpoint/routes/actions'; import { EndpointArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; @@ -162,19 +162,21 @@ export class Plugin implements IPlugin => Promise.resolve(config), - experimentalFeatures: parseExperimentalConfigValue(config.enableExperimental), + experimentalFeatures, }; initUsageCollectors({ core, endpointAppContext: endpointContext, kibanaIndex: globalConfig.kibana.index, + signalsIndex: config.signalsIndex, ml: plugins.ml, usageCollection: plugins.usageCollection, }); @@ -205,6 +207,7 @@ export class Plugin implements IPlugin { const securitySolutionSearchStrategy = securitySolutionSearchStrategyProvider(depsStart.data); diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index 488816cc6ad90..4ee6e12eca190 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -49,7 +49,8 @@ export const initRoutes = ( config: ConfigType, hasEncryptionKey: boolean, security: SetupPlugins['security'], - ml: SetupPlugins['ml'] + ml: SetupPlugins['ml'], + ruleRegistry: SetupPlugins['ruleRegistry'] ) => { // Detection Engine Rule routes that have the REST endpoints of /api/detection_engine/rules // All REST rule creation, deletion, updating, etc...... @@ -58,7 +59,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;