diff --git a/packages/kbn-babel-preset/styled_components_files.js b/packages/kbn-babel-preset/styled_components_files.js index 67db43277f2f1..954d83d8eb4cd 100644 --- a/packages/kbn-babel-preset/styled_components_files.js +++ b/packages/kbn-babel-preset/styled_components_files.js @@ -256,7 +256,6 @@ module.exports = { /x-pack[\/\\]solutions[\/\\]security[\/\\]plugins[\/\\]security_solution[\/\\]public[\/\\]detection_engine[\/\\]rule_exceptions[\/\\]components[\/\\]all_exception_items_table[\/\\]all_items.tsx/, /x-pack[\/\\]solutions[\/\\]security[\/\\]plugins[\/\\]security_solution[\/\\]public[\/\\]detection_engine[\/\\]rule_exceptions[\/\\]components[\/\\]all_exception_items_table[\/\\]index.tsx/, /x-pack[\/\\]solutions[\/\\]security[\/\\]plugins[\/\\]security_solution[\/\\]public[\/\\]detection_engine[\/\\]rule_exceptions[\/\\]components[\/\\]all_exception_items_table[\/\\]utility_bar.tsx/, - /x-pack[\/\\]solutions[\/\\]security[\/\\]plugins[\/\\]security_solution[\/\\]public[\/\\]detection_engine[\/\\]rule_exceptions[\/\\]components[\/\\]edit_exception_flyout[\/\\]index.test.tsx/, /x-pack[\/\\]solutions[\/\\]security[\/\\]plugins[\/\\]security_solution[\/\\]public[\/\\]detection_engine[\/\\]rule_exceptions[\/\\]components[\/\\]edit_exception_flyout[\/\\]index.tsx/, /x-pack[\/\\]solutions[\/\\]security[\/\\]plugins[\/\\]security_solution[\/\\]public[\/\\]detection_engine[\/\\]rule_exceptions[\/\\]components[\/\\]exception_item_card[\/\\]conditions.tsx/, /x-pack[\/\\]solutions[\/\\]security[\/\\]plugins[\/\\]security_solution[\/\\]public[\/\\]detection_engine[\/\\]rule_exceptions[\/\\]components[\/\\]exception_item_card[\/\\]header.test.tsx/, diff --git a/src/platform/packages/shared/kbn-openapi-generator/src/template_service/templates/api_client_supertest.handlebars b/src/platform/packages/shared/kbn-openapi-generator/src/template_service/templates/api_client_supertest.handlebars index 5f4d50440860e..09c201eb8c8a6 100644 --- a/src/platform/packages/shared/kbn-openapi-generator/src/template_service/templates/api_client_supertest.handlebars +++ b/src/platform/packages/shared/kbn-openapi-generator/src/template_service/templates/api_client_supertest.handlebars @@ -49,11 +49,11 @@ export function SecuritySolutionApiProvider({ getService }: FtrProviderContext) return { ...securitySolutionApiServiceFactory(supertestService), - withUser: (user: { username: string; password: string }) => { + withUser: (user: { username: string; password?: string }) => { const kbnUrl = formatUrl({ ...config.get('servers.kibana'), auth: false }); return securitySolutionApiServiceFactory( - supertest_.agent(kbnUrl).auth(user.username, user.password) + supertest_.agent(kbnUrl).auth(user.username, user.password ?? 'changeme') ); }, }; diff --git a/src/platform/packages/shared/kbn-securitysolution-rules/src/rule_type_constants.ts b/src/platform/packages/shared/kbn-securitysolution-rules/src/rule_type_constants.ts index 9aff793e18849..49be04c0baa4f 100644 --- a/src/platform/packages/shared/kbn-securitysolution-rules/src/rule_type_constants.ts +++ b/src/platform/packages/shared/kbn-securitysolution-rules/src/rule_type_constants.ts @@ -26,8 +26,8 @@ export const THRESHOLD_RULE_TYPE_ID = `${RULE_TYPE_PREFIX}.thresholdRule` as con export const NEW_TERMS_RULE_TYPE_ID = `${RULE_TYPE_PREFIX}.newTermsRule` as const; export const SECURITY_SOLUTION_RULE_TYPE_IDS = [ - EQL_RULE_TYPE_ID, ESQL_RULE_TYPE_ID, + EQL_RULE_TYPE_ID, INDICATOR_RULE_TYPE_ID, ML_RULE_TYPE_ID, QUERY_RULE_TYPE_ID, diff --git a/x-pack/platform/packages/shared/response-ops/alerts-table/hooks/use_toolbar_visibility.tsx b/x-pack/platform/packages/shared/response-ops/alerts-table/hooks/use_toolbar_visibility.tsx index 4bbdfb28f7ae6..34646abcc47b8 100644 --- a/x-pack/platform/packages/shared/response-ops/alerts-table/hooks/use_toolbar_visibility.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerts-table/hooks/use_toolbar_visibility.tsx @@ -239,7 +239,10 @@ export const useGetToolbarVisibility = ({ const defaultVisibility = useGetDefaultVisibility(defaultVisibilityProps); const options = useMemo(() => { const isBulkActionsActive = - selectedRowsCount === 0 || selectedRowsCount === undefined || bulkActions.length === 0; + selectedRowsCount === 0 || + selectedRowsCount === undefined || + bulkActions.length === 0 || + !bulkActions.some((panel) => panel.items?.length); if (isBulkActionsActive) { return { diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index cccf51b586e2d..1922577776489 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -36169,7 +36169,6 @@ "xpack.securitySolution.flyout.entityDetails.riskInputs": "Risikobeiträge anzeigen", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToExistingCase": "Zu einem bestehenden Fall hinzufügen", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToNewCase": "Zu einem neuen Fall hinzufügen", - "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToNewTimeline": "Zur neuen Zeitleiste hinzufügen", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.ariaLabel": "Aktionen", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.title": "Risikoeingang: {description}", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.titleDescription": "{quantity} ausgewählt", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index d3838fb19f8ea..a3e98204283a8 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -36547,7 +36547,6 @@ "xpack.securitySolution.flyout.entityDetails.riskInputs": "Voir les contributions au risque", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToExistingCase": "Ajouter à un cas existant", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToNewCase": "Ajouter au nouveau cas", - "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToNewTimeline": "Ajouter une nouvelle chronologie", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.ariaLabel": "Actions", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.title": "Entrée des risques : {description}", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.titleDescription": "{quantity} sélectionnée", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 67b7712cc5c80..8adec07cc78cb 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -36602,7 +36602,6 @@ "xpack.securitySolution.flyout.entityDetails.riskInputs": "リスク寄与を表示", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToExistingCase": "既存のケースに追加", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToNewCase": "新しいケースに追加", - "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToNewTimeline": "新規タイムラインに追加", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.ariaLabel": "アクション", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.title": "リスクインプット:{description}", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.titleDescription": "{quantity}選択済み", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index af7df20be3608..d7f1ee6c0184d 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -36582,7 +36582,6 @@ "xpack.securitySolution.flyout.entityDetails.riskInputs": "查看风险贡献", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToExistingCase": "添加到现有案例", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToNewCase": "添加到新案例", - "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.addToNewTimeline": "添加到新时间线", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.ariaLabel": "操作", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.title": "风险输入:{description}", "xpack.securitySolution.flyout.entityDetails.riskInputs.actions.titleDescription": "{quantity} 个已选定", diff --git a/x-pack/platform/test/api_integration/apis/features/features/features.ts b/x-pack/platform/test/api_integration/apis/features/features/features.ts index 202acfbea8143..f3305a96bdbeb 100644 --- a/x-pack/platform/test/api_integration/apis/features/features/features.ts +++ b/x-pack/platform/test/api_integration/apis/features/features/features.ts @@ -142,7 +142,8 @@ export default function ({ getService }: FtrProviderContext) { 'securitySolutionCasesV3', 'securitySolutionTimeline', 'securitySolutionNotes', - 'securitySolutionRulesV2', + 'securitySolutionRulesV3', + 'securitySolutionAlertsV1', 'securitySolutionSiemMigrations', 'workflowsManagement', 'fleet', diff --git a/x-pack/platform/test/api_integration/apis/security/privileges.ts b/x-pack/platform/test/api_integration/apis/security/privileges.ts index 46539abe8b5ba..d7b4b96c48bbd 100644 --- a/x-pack/platform/test/api_integration/apis/security/privileges.ts +++ b/x-pack/platform/test/api_integration/apis/security/privileges.ts @@ -351,6 +351,14 @@ export default function ({ getService }: FtrProviderContext) { 'minimal_read', 'security_solution_exceptions_all', ], + securitySolutionRulesV3: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'security_solution_exceptions_all', + ], + securitySolutionAlertsV1: ['all', 'read', 'minimal_all', 'minimal_read'], infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], logs: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read', 'manage_rules', 'manage_alerts'], diff --git a/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts b/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts index a9ebd46e35e48..443aaa8c53db5 100644 --- a/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts +++ b/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts @@ -63,6 +63,8 @@ export default function ({ getService }: FtrProviderContext) { siemV5: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionRulesV1: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionRulesV2: ['all', 'read', 'minimal_all', 'minimal_read'], + securitySolutionRulesV3: ['all', 'read', 'minimal_all', 'minimal_read'], + securitySolutionAlertsV1: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionAttackDiscovery: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -420,6 +422,14 @@ export default function ({ getService }: FtrProviderContext) { 'minimal_read', 'security_solution_exceptions_all', ], + securitySolutionRulesV3: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'security_solution_exceptions_all', + ], + securitySolutionAlertsV1: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionAssistant: [ 'all', 'read', diff --git a/x-pack/platform/test/security_api_integration/tests/features/deprecated_features.ts b/x-pack/platform/test/security_api_integration/tests/features/deprecated_features.ts index 382403068369a..24322720c6bcf 100644 --- a/x-pack/platform/test/security_api_integration/tests/features/deprecated_features.ts +++ b/x-pack/platform/test/security_api_integration/tests/features/deprecated_features.ts @@ -64,6 +64,20 @@ function getUserCredentials(username: string) { return `Basic ${Buffer.from(`${username}:changeme`).toString('base64')}`; } +const deprecatedApiActions: Record> = { + 'api:alerts-signal-update-deprecated-privilege': new Set([ + 'siem', + 'siemV2', + 'siemV3', + 'siemV4', + 'securitySolutionRulesV1', + 'securitySolutionRulesV2', + ]), +}; + +const isDeprecatedApiAction = ({ featureId, action }: { featureId: string; action: string }) => + deprecatedApiActions[action]?.has(featureId) ?? false; + export default function ({ getService }: FtrProviderContext) { describe('deprecated features', function () { const supertest = getService('supertest'); @@ -190,6 +204,7 @@ export default function ({ getService }: FtrProviderContext) { "securitySolutionCases", "securitySolutionCasesV2", "securitySolutionRulesV1", + "securitySolutionRulesV2", "siem", "siemV2", "siemV3", @@ -224,6 +239,8 @@ export default function ({ getService }: FtrProviderContext) { 'siemV2', 'siemV3', 'siemV4', + 'securitySolutionRulesV1', + 'securitySolutionRulesV2', ]); for (const feature of features) { if ( @@ -321,6 +338,7 @@ export default function ({ getService }: FtrProviderContext) { for (const deprecatedAction of deprecatedActions) { if ( isReplaceableAction(deprecatedAction) && + !isDeprecatedApiAction({ featureId: feature.id, action: deprecatedAction }) && !replacementActions.delete(deprecatedAction) ) { throw new Error( diff --git a/x-pack/platform/test/spaces_api_integration/common/suites/create.agnostic.ts b/x-pack/platform/test/spaces_api_integration/common/suites/create.agnostic.ts index e313f6a9ecfb5..1027d7284567e 100644 --- a/x-pack/platform/test/spaces_api_integration/common/suites/create.agnostic.ts +++ b/x-pack/platform/test/spaces_api_integration/common/suites/create.agnostic.ts @@ -88,11 +88,12 @@ export function createTestSuiteFactory({ getService }: DeploymentAgnosticFtrProv 'infrastructure', 'logs', 'observabilityCasesV3', + 'securitySolutionAlertsV1', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', 'securitySolutionCasesV3', 'securitySolutionNotes', - 'securitySolutionRulesV2', + 'securitySolutionRulesV3', 'securitySolutionSiemMigrations', 'securitySolutionTimeline', 'siemV5', diff --git a/x-pack/platform/test/spaces_api_integration/common/suites/get.agnostic.ts b/x-pack/platform/test/spaces_api_integration/common/suites/get.agnostic.ts index c5c657b632fc4..485b94e35636f 100644 --- a/x-pack/platform/test/spaces_api_integration/common/suites/get.agnostic.ts +++ b/x-pack/platform/test/spaces_api_integration/common/suites/get.agnostic.ts @@ -90,11 +90,12 @@ export function getTestSuiteFactory(context: DeploymentAgnosticFtrProviderContex 'infrastructure', 'logs', 'observabilityCasesV3', + 'securitySolutionAlertsV1', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', 'securitySolutionCasesV3', 'securitySolutionNotes', - 'securitySolutionRulesV2', + 'securitySolutionRulesV3', 'securitySolutionSiemMigrations', 'securitySolutionTimeline', 'siemV5', diff --git a/x-pack/platform/test/spaces_api_integration/common/suites/get_all.agnostic.ts b/x-pack/platform/test/spaces_api_integration/common/suites/get_all.agnostic.ts index 4d981158b8304..f8ab604e2d44e 100644 --- a/x-pack/platform/test/spaces_api_integration/common/suites/get_all.agnostic.ts +++ b/x-pack/platform/test/spaces_api_integration/common/suites/get_all.agnostic.ts @@ -80,11 +80,12 @@ const ALL_SPACE_RESULTS: Space[] = [ 'infrastructure', 'logs', 'observabilityCasesV3', + 'securitySolutionAlertsV1', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', 'securitySolutionCasesV3', 'securitySolutionNotes', - 'securitySolutionRulesV2', + 'securitySolutionRulesV3', 'securitySolutionSiemMigrations', 'securitySolutionTimeline', 'siemV5', diff --git a/x-pack/platform/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts b/x-pack/platform/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts index 16eca121836be..e7b6b7ea901c1 100644 --- a/x-pack/platform/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts +++ b/x-pack/platform/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts @@ -102,6 +102,8 @@ export default function ({ getService }: FtrProviderContext) { siemV5: 0, securitySolutionRulesV1: 0, securitySolutionRulesV2: 0, + securitySolutionRulesV3: 0, + securitySolutionAlertsV1: 0, securitySolutionCases: 0, securitySolutionCasesV2: 0, securitySolutionCasesV3: 0, diff --git a/x-pack/solutions/security/packages/features/README.mdx b/x-pack/solutions/security/packages/features/README.mdx index e87fe71c4fac9..54300e330985e 100644 --- a/x-pack/solutions/security/packages/features/README.mdx +++ b/x-pack/solutions/security/packages/features/README.mdx @@ -1,4 +1,84 @@ -## Security Solution App Features +# Security Solution Kibana features -This package provides resources to be used for Security Solution app features +This package (`@kbn/security-solution-features`) defines **Kibana feature registry** entries used by Elastic Security: base feature metadata, **privileges** (UI, API, saved objects, alerting), **sub-features** where applicable, and **product-feature** overlays that turn specific product capabilities on or off. +--- + +## Security + +**Feature id:** `siemV5` (`SECURITY_FEATURE_ID_V5`) + +**Display name:** Security + +**Role:** Controls access to the main Security Solution experience and related apps. It is the umbrella feature for navigating and using Security, including integration with **Cloud Security Posture** (`csp`) and **Defend for containers** (`cloudDefend`) apps where configured. + +**Base privileges** + +- **`all` / `read`:** Gate the Security Solution catalogue entry and apps (`securitySolution`, CSP, Cloud Defend, `kibana`). +- **UI:** `show` (read path) and `crud` (all path) map to broad Security UI capabilities. +- **API:** Core Security APIs, RAC (`rac`), list APIs (`lists-*`), user read, and `initialize-security-solution`. +- **Saved objects:** Read/write patterns depend on the privilege level and the saved-object types passed in at registration time; the `all` privilege includes the `alert` saved object type plus Security-related types from parameters. + +**Sub-features (v5):** Security is built as a **sub-feature–first** feature. The registry description states that **each sub-feature privilege must be assigned individually** when your pricing plan supports granular privileges; global assignment is only used when the plan does not allow per–sub-feature control. + +Sub-features include (non-exhaustive): endpoint host list and workflow insights, SOC management, global artifact management, trusted applications and devices, host isolation exceptions, blocklist, event filters, endpoint exceptions, policy and scripts management, response-actions history, host isolation, process/file operations, execute/scan actions, and related Endpoint capabilities. Some entries are gated by experimental feature flags. + +**Product features:** Additional keys in `ProductFeatureSecurityKey` (see `src/product_features_keys.ts` and `src/security/product_feature_config.ts`) layer **extra UI/API privileges** onto Security when those product capabilities are enabled—for example advanced insights, detections-related UI, threat intelligence, investigation guides, and Endpoint-specific behaviors. + +**Versioning:** Older Security feature ids (`siem`, `siemV2`–`siemV4`) exist for backward compatibility and migration; current work targets **`siemV5`**. + +--- + +## Rules + +**Feature id (current):** `securitySolutionRulesV3` (`RULES_FEATURE_ID_V3`, `RULES_FEATURE_LATEST`) + +**Display name:** Rules and Exceptions + +**Role:** Governs **creation, editing, and management of Security detection rules** and related **exception lists**, separate from the **Alerts** feature. It wires Security Solution rule types into Kibana **alerting** (rule-level privileges: create, enable, manual run, manage settings, read) and grants access to the **Stack Management → Rules** area (`insightsAndAlerting` / `triggersActions`). + +**Apps / catalogue:** Uses the `securitySolutionRules` app and the Security Solution catalogue id. + +**Base privileges** + +- **`all`:** Full rule and list APIs (`rules-*`, `lists-*`), user read, RAC, initialization; saved-object access for rule-related types (with exceptions for namespace-aware exception lists as defined in code). +- **`read`:** Read-only rule and list access, read exceptions API, and read-only alerting rule privileges. + +**Sub-features (v3):** **Exceptions** (`RulesSubFeatureId.exceptions`) is registered as a sub-feature so exception-list access can be granted with **minimal** privilege combinations alongside base Rules privileges. + +**Product features:** `ProductFeatureRulesKey` entries (for example `detections`, `externalDetections`) add targeted UI/API privileges—such as CSP-related APIs for detections—when those product slices are enabled. See `src/rules/product_feature_config.ts`. + +**Older versions** + +- **`securitySolutionRulesV1`:** Original combined rules feature. +- **`securitySolutionRulesV2`:** Deprecated; display name was “Rules, Alerts, and Exceptions.” Privileges were split so that **`securitySolutionRulesV3` + `securitySolutionAlertsV1`** replace the older combined model (`replacedBy` mappings in the v2 config). + +--- + +## Alerts + +**Feature id:** `securitySolutionAlertsV1` (`ALERTS_FEATURE_ID`) + +**Display name:** Alerts + +**Role:** Controls access to **alert documents** for Security detection and legacy notification rule types: viewing and updating alerts (status, assignment, tags, and so on), without bundling full **rule authoring** into the same feature. Alerting **alert** privileges (not rule management) are scoped to the same rule type ids as Rules (including `siem.notifications` where applicable). + +**Apps:** Uses `securitySolutionAlertsV1`, `kibana`, and `securitySolution` app ids so the Alerts surface can be authorized independently of Rules. + +**Base privileges** + +- **`all`:** `read_alerts` and `edit_alerts` UI capabilities; `alerts-read` / `alerts-all` APIs; RAC; user read; read access to **data views** (`index-pattern`) for querying alerts. +- **`read`:** Read-only alerts UI and APIs, read-only alerting alert privileges. + +**Product features:** `ProductFeatureAlertsKey` entries (`detections`, `externalDetections`) add UI flags such as `detections` / `external_detections` and optional APIs (for example `bulkGetUserProfiles` for detections on `all`). See `src/alerts/product_feature_config.ts`. + +**Compatibility:** Deprecated privilege strings (`alerts-signal-update-deprecated-privilege`, `edit_alerts-update-deprecated-privilege`) exist so older role assignments that implied alert updates under read APIs continue to work where explicitly mapped (for example in deprecated Rules v2). + +--- + +## How these pieces fit together + +- **Security (`siemV5`)** is the primary application and platform access feature for the Security UI, lists, and broad saved-object/API access; granular Endpoint and workflow controls are expressed as **sub-features**. +- **Rules** and **Alerts** split **rule lifecycle** vs **alert workflow** so administrators can assign detection engineers vs analysts with narrower scopes. + +For exact privilege strings and merge behavior, follow the source of truth in `src/constants.ts` and the corresponding `kibana_features.ts` / `kibana_sub_features.ts` files for each version. diff --git a/x-pack/solutions/security/packages/features/moon.yml b/x-pack/solutions/security/packages/features/moon.yml index c447bd80fe555..2999fa4d9ed6f 100644 --- a/x-pack/solutions/security/packages/features/moon.yml +++ b/x-pack/solutions/security/packages/features/moon.yml @@ -25,6 +25,7 @@ dependsOn: - '@kbn/securitysolution-rules' - '@kbn/securitysolution-list-constants' - '@kbn/elastic-assistant-common' + - '@kbn/data-views-plugin' tags: - shared-common - package diff --git a/x-pack/solutions/security/packages/features/product_features.ts b/x-pack/solutions/security/packages/features/product_features.ts index cda26e94ee370..0fbde54655364 100644 --- a/x-pack/solutions/security/packages/features/product_features.ts +++ b/x-pack/solutions/security/packages/features/product_features.ts @@ -18,4 +18,5 @@ export { getAttackDiscoveryFeature } from './src/attack_discovery'; export { getTimelineFeature } from './src/timeline'; export { getNotesFeature } from './src/notes'; export { getSiemMigrationsFeature } from './src/siem_migrations'; -export { getRulesFeature, getRulesV2Feature } from './src/rules'; +export { getRulesFeature, getRulesV2Feature, getRulesV3Feature } from './src/rules'; +export { getAlertsFeature } from './src/alerts'; diff --git a/x-pack/solutions/security/packages/features/src/alerts/index.ts b/x-pack/solutions/security/packages/features/src/alerts/index.ts new file mode 100644 index 0000000000000..0410cd37cd69c --- /dev/null +++ b/x-pack/solutions/security/packages/features/src/alerts/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ProductFeatureAlertsKey } from '../product_features_keys'; +import { getAlertsBaseKibanaFeature } from './kibana_features'; +import type { ProductFeatureParams } from '../types'; +import { alertsDefaultProductFeaturesConfig } from './product_feature_config'; + +export const getAlertsFeature = (): ProductFeatureParams => ({ + baseKibanaFeature: getAlertsBaseKibanaFeature(), + baseKibanaSubFeatureIds: [], + subFeaturesMap: new Map(), + productFeatureConfig: alertsDefaultProductFeaturesConfig, +}); diff --git a/x-pack/solutions/security/packages/features/src/alerts/kibana_features.ts b/x-pack/solutions/security/packages/features/src/alerts/kibana_features.ts new file mode 100644 index 0000000000000..fbf1f6ac5da6b --- /dev/null +++ b/x-pack/solutions/security/packages/features/src/alerts/kibana_features.ts @@ -0,0 +1,74 @@ +/* + * 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 { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; +import { i18n } from '@kbn/i18n'; +import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; +import { + ALERTS_API_ALL, + ALERTS_API_READ, + APP_ID, + LEGACY_NOTIFICATIONS_ID, + ALERTS_FEATURE_ID, + SERVER_APP_ID, + ALERTS_UI_READ, + ALERTS_UI_EDIT, + INITIALIZE_SECURITY_SOLUTION, + USERS_API_READ, +} from '../constants'; +import { type BaseKibanaFeatureConfig } from '../types'; + +const alertingFeatures = [LEGACY_NOTIFICATIONS_ID, ...SECURITY_SOLUTION_RULE_TYPE_IDS].map( + (ruleTypeId) => ({ + ruleTypeId, + consumers: [SERVER_APP_ID], + }) +); + +export const getAlertsBaseKibanaFeature = (): BaseKibanaFeatureConfig => ({ + id: ALERTS_FEATURE_ID, + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionRolesAlertsTitle', + { + defaultMessage: 'Alerts', + } + ), + order: 1100, + category: DEFAULT_APP_CATEGORIES.security, + app: [ALERTS_FEATURE_ID, 'kibana', 'securitySolution'], + catalogue: [APP_ID], + alerting: alertingFeatures, + privileges: { + all: { + app: ['securitySolution', ALERTS_FEATURE_ID, 'kibana'], + catalogue: [APP_ID], + savedObject: { + all: [], + read: [DATA_VIEW_SAVED_OBJECT_TYPE], + }, + alerting: { + alert: { all: alertingFeatures }, + }, + ui: [ALERTS_UI_READ, ALERTS_UI_EDIT], + api: ['rac', INITIALIZE_SECURITY_SOLUTION, ALERTS_API_ALL, ALERTS_API_READ, USERS_API_READ], + }, + read: { + app: [ALERTS_FEATURE_ID, 'kibana', 'securitySolution'], + catalogue: [APP_ID], + savedObject: { + all: [], + read: [DATA_VIEW_SAVED_OBJECT_TYPE], + }, + alerting: { + alert: { read: alertingFeatures }, + }, + ui: [ALERTS_UI_READ], + api: ['rac', INITIALIZE_SECURITY_SOLUTION, ALERTS_API_READ, USERS_API_READ], + }, + }, +}); diff --git a/x-pack/solutions/security/packages/features/src/alerts/product_feature_config.ts b/x-pack/solutions/security/packages/features/src/alerts/product_feature_config.ts new file mode 100644 index 0000000000000..daad563e84a9c --- /dev/null +++ b/x-pack/solutions/security/packages/features/src/alerts/product_feature_config.ts @@ -0,0 +1,36 @@ +/* + * 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 { ProductFeatureAlertsKey } from '../product_features_keys'; +import type { AlertsProductFeaturesConfig } from './types'; + +export const alertsDefaultProductFeaturesConfig: AlertsProductFeaturesConfig = { + [ProductFeatureAlertsKey.externalDetections]: { + privileges: { + all: { + ui: ['external_detections'], + api: [], + }, + read: { + ui: ['external_detections'], + api: [], + }, + }, + }, + [ProductFeatureAlertsKey.detections]: { + privileges: { + all: { + ui: ['detections'], + api: ['bulkGetUserProfiles'], + }, + read: { + ui: ['detections'], + api: [], + }, + }, + }, +}; diff --git a/x-pack/solutions/security/packages/features/src/alerts/types.ts b/x-pack/solutions/security/packages/features/src/alerts/types.ts new file mode 100644 index 0000000000000..845fc6468d3db --- /dev/null +++ b/x-pack/solutions/security/packages/features/src/alerts/types.ts @@ -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. + */ + +import type { ProductFeatureAlertsKey } from '../product_features_keys'; +import type { ProductFeaturesConfig } from '../types'; + +export type AlertsProductFeaturesConfig = ProductFeaturesConfig; diff --git a/x-pack/solutions/security/packages/features/src/constants.ts b/x-pack/solutions/security/packages/features/src/constants.ts index 5d3d0c428125b..14d22b348c7ac 100644 --- a/x-pack/solutions/security/packages/features/src/constants.ts +++ b/x-pack/solutions/security/packages/features/src/constants.ts @@ -9,6 +9,8 @@ export const APP_ID = 'securitySolution' as const; export const SERVER_APP_ID = 'siem' as const; +export const SECURITY_FEATURE_ID_V1 = SERVER_APP_ID; + // New version created in 8.18. It was previously `SERVER_APP_ID`. export const SECURITY_FEATURE_ID_V2 = 'siemV2' as const; // New version for 9.1. @@ -49,13 +51,12 @@ export const SIEM_MIGRATIONS_FEATURE_ID = 'securitySolutionSiemMigrations' as co export const SECURITY_SOLUTION_RULES_APP_ID = 'securitySolutionRules' as const; export const RULES_FEATURE_ID_V1 = 'securitySolutionRulesV1' as const; export const RULES_FEATURE_ID_V2 = 'securitySolutionRulesV2' as const; -export const RULES_FEATURE_LATEST = RULES_FEATURE_ID_V2; +export const RULES_FEATURE_ID_V3 = 'securitySolutionRulesV3' as const; +export const RULES_FEATURE_LATEST = RULES_FEATURE_ID_V3; // Rules API privileges export const RULES_API_READ = 'rules-read' as const; export const RULES_API_ALL = 'rules-all' as const; -export const ALERTS_API_READ = 'alerts-read' as const; -export const ALERTS_API_ALL = 'alerts-all' as const; export const EXCEPTIONS_API_READ = 'exceptions-read' as const; export const EXCEPTIONS_API_ALL = 'exceptions-all' as const; export const LISTS_API_READ = 'lists-read' as const; @@ -68,19 +69,45 @@ export const USERS_API_READ = 'users-read' as const; export const RULES_UI_READ = 'read_rules' as const; export const RULES_UI_DETECTIONS = 'detections' as const; export const RULES_UI_EXTERNAL_DETECTIONS = 'external_detections' as const; -export const RULES_UI_READ_PRIVILEGE = `${RULES_FEATURE_ID_V2}.${RULES_UI_READ}` as const; +export const RULES_UI_READ_PRIVILEGE = `${RULES_FEATURE_ID_V3}.${RULES_UI_READ}` as const; export const RULES_UI_EDIT = 'edit_rules' as const; -export const RULES_UI_EDIT_PRIVILEGE = `${RULES_FEATURE_ID_V2}.${RULES_UI_EDIT}` as const; +export const RULES_UI_EDIT_PRIVILEGE = `${RULES_FEATURE_ID_V3}.${RULES_UI_EDIT}` as const; export const RULES_UI_DETECTIONS_PRIVILEGE = - `${RULES_FEATURE_ID_V2}.${RULES_UI_DETECTIONS}` as const; + `${RULES_FEATURE_ID_V3}.${RULES_UI_DETECTIONS}` as const; export const RULES_UI_EXTERNAL_DETECTIONS_PRIVILEGE = - `${RULES_FEATURE_ID_V2}.${RULES_UI_EXTERNAL_DETECTIONS}` as const; + `${RULES_FEATURE_ID_V3}.${RULES_UI_EXTERNAL_DETECTIONS}` as const; export const EXCEPTIONS_UI_READ = 'readExceptions' as const; export const EXCEPTIONS_UI_EDIT = 'editExceptions' as const; export const EXCEPTIONS_UI_READ_PRIVILEGES = - `${RULES_FEATURE_ID_V2}.${EXCEPTIONS_UI_READ}` as const; + `${RULES_FEATURE_ID_V3}.${EXCEPTIONS_UI_READ}` as const; export const EXCEPTIONS_UI_EDIT_PRIVILEGES = - `${RULES_FEATURE_ID_V2}.${EXCEPTIONS_UI_EDIT}` as const; + `${RULES_FEATURE_ID_V3}.${EXCEPTIONS_UI_EDIT}` as const; + +export const ALERTS_FEATURE_ID = 'securitySolutionAlertsV1' as const; + +// Alerts API privileges +export const ALERTS_API_READ = 'alerts-read' as const; +export const ALERTS_API_ALL = 'alerts-all' as const; + +// Alerts UI privileges +export const ALERTS_UI_READ = 'read_alerts' as const; +export const ALERTS_UI_EDIT = 'edit_alerts' as const; +export const ALERTS_UI_DETECTIONS = 'detections' as const; +export const ALERTS_UI_EXTERNAL_DETECTIONS = 'external_detections' as const; +export const ALERTS_UI_READ_PRIVILEGE = `${ALERTS_FEATURE_ID}.${ALERTS_UI_READ}` as const; +export const ALERTS_UI_EDIT_PRIVILEGE = `${ALERTS_FEATURE_ID}.${ALERTS_UI_EDIT}` as const; +export const ALERTS_UI_DETECTIONS_PRIVILEGE = + `${ALERTS_FEATURE_ID}.${ALERTS_UI_DETECTIONS}` as const; +export const ALERTS_UI_EXTERNAL_DETECTIONS_PRIVILEGE = + `${ALERTS_FEATURE_ID}.${ALERTS_UI_EXTERNAL_DETECTIONS}` as const; + +// Previously users with "ALERTS_API_READ" were able to update alerts (assign, tag, change status) +// When the alerts feature was introduced, the privilege in the endpoints was changed to `ALERTS_API_ALL` +// however to ensure backwards compatibility with old features, these permissions were added in the affected places. +export const ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE = + 'alerts-signal-update-deprecated-privilege' as const; +export const ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE = + 'edit_alerts-update-deprecated-privilege' as const; // Same as the plugin id defined by Cloud Security Posture export const CLOUD_POSTURE_APP_ID = 'csp' as const; diff --git a/x-pack/solutions/security/packages/features/src/product_features_keys.ts b/x-pack/solutions/security/packages/features/src/product_features_keys.ts index 699325cbeb5c8..060f5fc6f8227 100644 --- a/x-pack/solutions/security/packages/features/src/product_features_keys.ts +++ b/x-pack/solutions/security/packages/features/src/product_features_keys.ts @@ -186,6 +186,14 @@ export enum ProductFeatureRulesKey { exceptions = 'exceptions', } +export enum ProductFeatureAlertsKey { + /** Elastic endpoint detections, includes alerts, rules, investigations */ + detections = 'detections', + + /** Enables external detections for AI SOC, includes alerts_summary, basic_rules*/ + externalDetections = 'external_detections', +} + // Merges the two enums. export const ProductFeatureKey = { ...ProductFeatureSecurityKey, @@ -196,6 +204,7 @@ export const ProductFeatureKey = { ...ProductFeatureTimelineKey, ...ProductFeatureNotesKey, ...ProductFeatureRulesKey, + ...ProductFeatureAlertsKey, }; // We need to merge the value and the type and export both to replicate how enum works. export type ProductFeatureKeyType = @@ -206,7 +215,8 @@ export type ProductFeatureKeyType = | ProductFeatureSiemMigrationsKey | ProductFeatureTimelineKey | ProductFeatureNotesKey - | ProductFeatureRulesKey; + | ProductFeatureRulesKey + | ProductFeatureAlertsKey; export const ALL_PRODUCT_FEATURE_KEYS = Object.freeze(Object.values(ProductFeatureKey)); diff --git a/x-pack/solutions/security/packages/features/src/rules/index.ts b/x-pack/solutions/security/packages/features/src/rules/index.ts index a0b78e72dae39..321ca2ba4badb 100644 --- a/x-pack/solutions/security/packages/features/src/rules/index.ts +++ b/x-pack/solutions/security/packages/features/src/rules/index.ts @@ -15,6 +15,11 @@ import { getRulesSubFeaturesMapV2, } from './v2_features/kibana_sub_features'; import type { ProductFeatureRulesKey, RulesSubFeatureId } from '../product_features_keys'; +import { + getRulesBaseKibanaSubFeatureIdsV3, + getRulesSubFeaturesMapV3, +} from './v3_features/kibana_sub_features'; +import { getRulesV3BaseKibanaFeature } from './v3_features/kibana_features'; export const getRulesFeature = ( params: SecurityFeatureParams @@ -33,3 +38,12 @@ export const getRulesV2Feature = ( subFeaturesMap: getRulesSubFeaturesMapV2(), productFeatureConfig: rulesDefaultProductFeaturesConfig, }); + +export const getRulesV3Feature = ( + params: SecurityFeatureParams +): ProductFeatureParams => ({ + baseKibanaFeature: getRulesV3BaseKibanaFeature(params), + baseKibanaSubFeatureIds: getRulesBaseKibanaSubFeatureIdsV3(), + subFeaturesMap: getRulesSubFeaturesMapV3(), + productFeatureConfig: rulesDefaultProductFeaturesConfig, +}); diff --git a/x-pack/solutions/security/packages/features/src/rules/v1_features/kibana_features.ts b/x-pack/solutions/security/packages/features/src/rules/v1_features/kibana_features.ts index b35e34a1fb16d..f85ef5b43d588 100644 --- a/x-pack/solutions/security/packages/features/src/rules/v1_features/kibana_features.ts +++ b/x-pack/solutions/security/packages/features/src/rules/v1_features/kibana_features.ts @@ -8,16 +8,7 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; import { i18n } from '@kbn/i18n'; -import { - ESQL_RULE_TYPE_ID, - EQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, -} from '@kbn/securitysolution-rules'; +import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; import { ALERTS_API_ALL, ALERTS_API_READ, @@ -32,33 +23,26 @@ import { RULES_API_ALL, RULES_API_READ, RULES_FEATURE_ID_V1, - RULES_FEATURE_ID_V2, + RULES_FEATURE_ID_V3, RULES_UI_EDIT, RULES_UI_READ, SERVER_APP_ID, USERS_API_READ, EXCEPTIONS_SUBFEATURE_ALL, SECURITY_SOLUTION_RULES_APP_ID, + ALERTS_FEATURE_ID, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, + ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE, } from '../../constants'; import { type BaseKibanaFeatureConfig } from '../../types'; import type { SecurityFeatureParams } from '../../security/types'; -const SECURITY_RULE_TYPES = [ - LEGACY_NOTIFICATIONS_ID, - ESQL_RULE_TYPE_ID, - EQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, -]; - -const alertingFeatures = SECURITY_RULE_TYPES.map((ruleTypeId) => ({ - ruleTypeId, - consumers: [SERVER_APP_ID], -})); +const alertingFeatures = [LEGACY_NOTIFICATIONS_ID, ...SECURITY_SOLUTION_RULE_TYPE_IDS].map( + (ruleTypeId) => ({ + ruleTypeId, + consumers: [SERVER_APP_ID], + }) +); export const getRulesBaseKibanaFeature = ( params: SecurityFeatureParams @@ -70,14 +54,14 @@ export const getRulesBaseKibanaFeature = ( defaultMessage: 'The {currentId} permissions are deprecated, please see {latestId}.', values: { currentId: RULES_FEATURE_ID_V1, - latestId: RULES_FEATURE_ID_V2, + latestId: RULES_FEATURE_ID_V3, }, } ), }, id: RULES_FEATURE_ID_V1, name: i18n.translate( - 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionRolesTitle', + 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionRulesV1Title', { defaultMessage: 'Rules, Alerts, and Exceptions', } @@ -93,12 +77,16 @@ export const getRulesBaseKibanaFeature = ( privileges: { all: { replacedBy: { - default: [{ feature: RULES_FEATURE_ID_V2, privileges: ['all'] }], + default: [ + { feature: RULES_FEATURE_ID_V3, privileges: ['all'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['all'] }, + ], minimal: [ { - feature: RULES_FEATURE_ID_V2, + feature: RULES_FEATURE_ID_V3, privileges: ['minimal_all', EXCEPTIONS_SUBFEATURE_ALL], }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_all'] }, ], }, app: [SECURITY_SOLUTION_RULES_APP_ID, 'kibana'], @@ -137,12 +125,16 @@ export const getRulesBaseKibanaFeature = ( }, read: { replacedBy: { - default: [{ feature: RULES_FEATURE_ID_V2, privileges: ['read'] }], + default: [ + { feature: RULES_FEATURE_ID_V3, privileges: ['read'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['read'] }, + ], minimal: [ { - feature: RULES_FEATURE_ID_V2, + feature: RULES_FEATURE_ID_V3, privileges: ['minimal_read'], }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_read'] }, ], }, app: [SECURITY_SOLUTION_RULES_APP_ID, 'kibana'], @@ -158,7 +150,7 @@ export const getRulesBaseKibanaFeature = ( management: { insightsAndAlerting: ['triggersActions'], // Access to the stack rules management UI }, - ui: [RULES_UI_READ], + ui: [RULES_UI_READ, ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE], api: [ RULES_API_READ, ALERTS_API_READ, @@ -166,6 +158,7 @@ export const getRulesBaseKibanaFeature = ( LISTS_API_READ, USERS_API_READ, INITIALIZE_SECURITY_SOLUTION, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, 'rac', ], }, diff --git a/x-pack/solutions/security/packages/features/src/rules/v2_features/kibana_features.ts b/x-pack/solutions/security/packages/features/src/rules/v2_features/kibana_features.ts index eb2fb6497f76d..72d0c566ac40c 100644 --- a/x-pack/solutions/security/packages/features/src/rules/v2_features/kibana_features.ts +++ b/x-pack/solutions/security/packages/features/src/rules/v2_features/kibana_features.ts @@ -8,16 +8,7 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; import { i18n } from '@kbn/i18n'; -import { - ESQL_RULE_TYPE_ID, - EQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, -} from '@kbn/securitysolution-rules'; +import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; import { EXCEPTION_LIST_NAMESPACE_AWARE } from '@kbn/securitysolution-list-constants'; import { @@ -34,35 +25,42 @@ import { RULES_API_ALL, RULES_API_READ, RULES_FEATURE_ID_V2, + RULES_FEATURE_ID_V3, RULES_UI_EDIT, RULES_UI_READ, SECURITY_SOLUTION_RULES_APP_ID, SERVER_APP_ID, USERS_API_READ, + ALERTS_FEATURE_ID, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, + ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE, + EXCEPTIONS_SUBFEATURE_ALL, } from '../../constants'; import { type BaseKibanaFeatureConfig } from '../../types'; import type { SecurityFeatureParams } from '../../security/types'; -const SECURITY_RULE_TYPES = [ - LEGACY_NOTIFICATIONS_ID, - ESQL_RULE_TYPE_ID, - EQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, -]; - -const alertingFeatures = SECURITY_RULE_TYPES.map((ruleTypeId) => ({ - ruleTypeId, - consumers: [SERVER_APP_ID], -})); +const alertingFeatures = [LEGACY_NOTIFICATIONS_ID, ...SECURITY_SOLUTION_RULE_TYPE_IDS].map( + (ruleTypeId) => ({ + ruleTypeId, + consumers: [SERVER_APP_ID], + }) +); export const getRulesV2BaseKibanaFeature = ( params: SecurityFeatureParams ): BaseKibanaFeatureConfig => ({ + deprecated: { + notice: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionSecurity.deprecationMessage', + { + defaultMessage: 'The {currentId} permissions are deprecated, please see {latestId}.', + values: { + currentId: RULES_FEATURE_ID_V2, + latestId: RULES_FEATURE_ID_V3, + }, + } + ), + }, id: RULES_FEATURE_ID_V2, name: i18n.translate( 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionRulesV2Title', @@ -80,6 +78,19 @@ export const getRulesV2BaseKibanaFeature = ( }, privileges: { all: { + replacedBy: { + default: [ + { feature: RULES_FEATURE_ID_V3, privileges: ['all'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['all'] }, + ], + minimal: [ + { + feature: RULES_FEATURE_ID_V3, + privileges: ['minimal_all', EXCEPTIONS_SUBFEATURE_ALL], + }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_all'] }, + ], + }, app: [SECURITY_SOLUTION_RULES_APP_ID, 'kibana'], catalogue: [APP_ID], savedObject: { @@ -114,6 +125,19 @@ export const getRulesV2BaseKibanaFeature = ( ], }, read: { + replacedBy: { + default: [ + { feature: RULES_FEATURE_ID_V3, privileges: ['read'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['read'] }, + ], + minimal: [ + { + feature: RULES_FEATURE_ID_V3, + privileges: ['minimal_read'], + }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_read'] }, + ], + }, app: [SECURITY_SOLUTION_RULES_APP_ID, 'kibana'], catalogue: [APP_ID], savedObject: { @@ -127,10 +151,11 @@ export const getRulesV2BaseKibanaFeature = ( management: { insightsAndAlerting: ['triggersActions'], // Access to the stack rules management UI }, - ui: [RULES_UI_READ, EXCEPTIONS_UI_READ], + ui: [RULES_UI_READ, EXCEPTIONS_UI_READ, ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE], api: [ RULES_API_READ, ALERTS_API_READ, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, LISTS_API_READ, EXCEPTIONS_API_READ, USERS_API_READ, diff --git a/x-pack/solutions/security/packages/features/src/rules/v2_features/kibana_sub_features.ts b/x-pack/solutions/security/packages/features/src/rules/v2_features/kibana_sub_features.ts index e5f88fc3993d8..94f0c8d9fd782 100644 --- a/x-pack/solutions/security/packages/features/src/rules/v2_features/kibana_sub_features.ts +++ b/x-pack/solutions/security/packages/features/src/rules/v2_features/kibana_sub_features.ts @@ -8,6 +8,8 @@ import type { SubFeatureConfig } from '@kbn/features-plugin/common'; import { RulesSubFeatureId } from '../../product_features_keys'; import { getExceptionsSubFeature } from '../kibana_sub_features'; +import { addAllSubFeatureReplacements } from '../../utils'; +import { RULES_FEATURE_ID_V3 } from '../../constants'; export const getRulesBaseKibanaSubFeatureIdsV2 = (): RulesSubFeatureId[] => [ RulesSubFeatureId.exceptions, @@ -18,7 +20,9 @@ export const getRulesBaseKibanaSubFeatureIdsV2 = (): RulesSubFeatureId[] => [ * The order of the subFeatures is the order they will be displayed */ export const getRulesSubFeaturesMapV2 = () => { - return new Map([ + const subFeaturesList = new Map([ [RulesSubFeatureId.exceptions, getExceptionsSubFeature()], ]); + + return addAllSubFeatureReplacements(subFeaturesList, [{ feature: RULES_FEATURE_ID_V3 }]); }; diff --git a/x-pack/solutions/security/packages/features/src/rules/v3_features/kibana_features.ts b/x-pack/solutions/security/packages/features/src/rules/v3_features/kibana_features.ts new file mode 100644 index 0000000000000..4de6a7d3d2e39 --- /dev/null +++ b/x-pack/solutions/security/packages/features/src/rules/v3_features/kibana_features.ts @@ -0,0 +1,115 @@ +/* + * 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 { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; +import { i18n } from '@kbn/i18n'; + +import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; +import { EXCEPTION_LIST_NAMESPACE_AWARE } from '@kbn/securitysolution-list-constants'; + +import { + APP_ID, + EXCEPTIONS_API_READ, + EXCEPTIONS_UI_READ, + INITIALIZE_SECURITY_SOLUTION, + LEGACY_NOTIFICATIONS_ID, + LISTS_API_ALL, + LISTS_API_READ, + LISTS_API_SUMMARY, + RULES_API_ALL, + RULES_API_READ, + RULES_FEATURE_ID_V3, + RULES_UI_EDIT, + RULES_UI_READ, + SECURITY_SOLUTION_RULES_APP_ID, + SERVER_APP_ID, + USERS_API_READ, +} from '../../constants'; +import { type BaseKibanaFeatureConfig } from '../../types'; +import type { SecurityFeatureParams } from '../../security/types'; + +const alertingFeatures = [LEGACY_NOTIFICATIONS_ID, ...SECURITY_SOLUTION_RULE_TYPE_IDS].map( + (ruleTypeId) => ({ + ruleTypeId, + consumers: [SERVER_APP_ID], + }) +); + +export const getRulesV3BaseKibanaFeature = ( + params: SecurityFeatureParams +): BaseKibanaFeatureConfig => ({ + id: RULES_FEATURE_ID_V3, + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionRulesV3Title', + { + defaultMessage: 'Rules and Exceptions', + } + ), + order: 1100, + category: DEFAULT_APP_CATEGORIES.security, + app: [SECURITY_SOLUTION_RULES_APP_ID, 'kibana'], + catalogue: [APP_ID], + alerting: alertingFeatures, + management: { + insightsAndAlerting: ['triggersActions'], // Access to the stack rules management UI + }, + privileges: { + all: { + app: [SECURITY_SOLUTION_RULES_APP_ID, 'kibana'], + catalogue: [APP_ID], + savedObject: { + all: params.savedObjects.filter((so) => so !== EXCEPTION_LIST_NAMESPACE_AWARE), + read: params.savedObjects, + }, + alerting: { + rule: { + all: alertingFeatures, + enable: alertingFeatures, + manual_run: alertingFeatures, + manage_rule_settings: alertingFeatures, + }, + }, + management: { + insightsAndAlerting: ['triggersActions'], // Access to the stack rules management UI + }, + ui: [RULES_UI_READ, RULES_UI_EDIT], + api: [ + RULES_API_ALL, + RULES_API_READ, + LISTS_API_ALL, + LISTS_API_READ, + LISTS_API_SUMMARY, + USERS_API_READ, + INITIALIZE_SECURITY_SOLUTION, + 'rac', + ], + }, + read: { + app: [SECURITY_SOLUTION_RULES_APP_ID, 'kibana'], + catalogue: [APP_ID], + savedObject: { + all: [], + read: params.savedObjects, + }, + alerting: { + rule: { read: alertingFeatures }, + }, + management: { + insightsAndAlerting: ['triggersActions'], // Access to the stack rules management UI + }, + ui: [RULES_UI_READ, EXCEPTIONS_UI_READ], + api: [ + RULES_API_READ, + LISTS_API_READ, + USERS_API_READ, + EXCEPTIONS_API_READ, + INITIALIZE_SECURITY_SOLUTION, + 'rac', + ], + }, + }, +}); diff --git a/x-pack/solutions/security/packages/features/src/rules/v3_features/kibana_sub_features.ts b/x-pack/solutions/security/packages/features/src/rules/v3_features/kibana_sub_features.ts new file mode 100644 index 0000000000000..c89d9fae01e63 --- /dev/null +++ b/x-pack/solutions/security/packages/features/src/rules/v3_features/kibana_sub_features.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 type { SubFeatureConfig } from '@kbn/features-plugin/common'; +import { RulesSubFeatureId } from '../../product_features_keys'; +import { getExceptionsSubFeature } from '../kibana_sub_features'; + +export const getRulesBaseKibanaSubFeatureIdsV3 = (): RulesSubFeatureId[] => [ + RulesSubFeatureId.exceptions, +]; + +/** + * Defines all the Security Solution Rules subFeatures available. + * The order of the subFeatures is the order they will be displayed + */ +export const getRulesSubFeaturesMapV3 = () => { + return new Map([ + [RulesSubFeatureId.exceptions, getExceptionsSubFeature()], + ]); +}; diff --git a/x-pack/solutions/security/packages/features/src/security/v1_features/kibana_features.ts b/x-pack/solutions/security/packages/features/src/security/v1_features/kibana_features.ts index 085136b212d28..869219bbe6998 100644 --- a/x-pack/solutions/security/packages/features/src/security/v1_features/kibana_features.ts +++ b/x-pack/solutions/security/packages/features/src/security/v1_features/kibana_features.ts @@ -8,16 +8,7 @@ import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; -import { - EQL_RULE_TYPE_ID, - ESQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, -} from '@kbn/securitysolution-rules'; +import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; import { APP_ID, SERVER_APP_ID, @@ -30,7 +21,7 @@ import { LISTS_API_SUMMARY, LISTS_API_READ, LISTS_API_ALL, - RULES_FEATURE_ID_V2, + RULES_FEATURE_ID_V3, SECURITY_UI_SHOW, SECURITY_UI_CRUD, INITIALIZE_SECURITY_SOLUTION, @@ -42,26 +33,19 @@ import { EXCEPTIONS_API_READ, USERS_API_READ, EXCEPTIONS_SUBFEATURE_ALL, + ALERTS_FEATURE_ID, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, + ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE, } from '../../constants'; import type { SecurityFeatureParams } from '../types'; import type { BaseKibanaFeatureConfig } from '../../types'; -const SECURITY_RULE_TYPES = [ - LEGACY_NOTIFICATIONS_ID, - ESQL_RULE_TYPE_ID, - EQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, -]; - -const alertingFeatures = SECURITY_RULE_TYPES.map((ruleTypeId) => ({ - ruleTypeId, - consumers: [SERVER_APP_ID], -})); +const alertingFeatures = [LEGACY_NOTIFICATIONS_ID, ...SECURITY_SOLUTION_RULE_TYPE_IDS].map( + (ruleTypeId) => ({ + ruleTypeId, + consumers: [SERVER_APP_ID], + }) +); export const getSecurityBaseKibanaFeature = ({ savedObjects, @@ -110,7 +94,8 @@ export const getSecurityBaseKibanaFeature = ({ { feature: NOTES_FEATURE_ID, privileges: ['all'] }, // note: overriden by product feature endpointArtifactManagement when enabled { feature: SECURITY_FEATURE_ID_V5, privileges: ['all'] }, - { feature: RULES_FEATURE_ID_V2, privileges: ['all'] }, + { feature: RULES_FEATURE_ID_V3, privileges: ['all'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['all'] }, ], minimal: [ { feature: TIMELINE_FEATURE_ID, privileges: ['all'] }, @@ -118,9 +103,10 @@ export const getSecurityBaseKibanaFeature = ({ // note: overriden by product feature endpointArtifactManagement when enabled { feature: SECURITY_FEATURE_ID_V5, privileges: ['minimal_all'] }, { - feature: RULES_FEATURE_ID_V2, + feature: RULES_FEATURE_ID_V3, privileges: ['minimal_all', EXCEPTIONS_SUBFEATURE_ALL], }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_all'] }, ], }, app: [APP_ID, CLOUD_POSTURE_APP_ID, CLOUD_DEFEND_APP_ID, 'kibana'], @@ -172,16 +158,18 @@ export const getSecurityBaseKibanaFeature = ({ { feature: TIMELINE_FEATURE_ID, privileges: ['read'] }, { feature: NOTES_FEATURE_ID, privileges: ['read'] }, { feature: SECURITY_FEATURE_ID_V5, privileges: ['read'] }, - { feature: RULES_FEATURE_ID_V2, privileges: ['read'] }, + { feature: RULES_FEATURE_ID_V3, privileges: ['read'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['read'] }, ], minimal: [ { feature: TIMELINE_FEATURE_ID, privileges: ['read'] }, { feature: NOTES_FEATURE_ID, privileges: ['read'] }, { feature: SECURITY_FEATURE_ID_V5, privileges: ['minimal_read'] }, { - feature: RULES_FEATURE_ID_V2, + feature: RULES_FEATURE_ID_V3, privileges: ['minimal_read'], }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_read'] }, ], }, app: [APP_ID, CLOUD_POSTURE_APP_ID, CLOUD_DEFEND_APP_ID, 'kibana'], @@ -191,6 +179,7 @@ export const getSecurityBaseKibanaFeature = ({ LISTS_API_READ, RULES_API_READ, ALERTS_API_READ, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, EXCEPTIONS_API_READ, USERS_API_READ, INITIALIZE_SECURITY_SOLUTION, @@ -214,7 +203,7 @@ export const getSecurityBaseKibanaFeature = ({ management: { insightsAndAlerting: ['triggersActions'], }, - ui: [SECURITY_UI_SHOW], + ui: [SECURITY_UI_SHOW, ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE], }, }, }); diff --git a/x-pack/solutions/security/packages/features/src/security/v2_features/kibana_features.ts b/x-pack/solutions/security/packages/features/src/security/v2_features/kibana_features.ts index 40792ad9eba4b..b5a03b7c691ee 100644 --- a/x-pack/solutions/security/packages/features/src/security/v2_features/kibana_features.ts +++ b/x-pack/solutions/security/packages/features/src/security/v2_features/kibana_features.ts @@ -8,16 +8,7 @@ import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; -import { - EQL_RULE_TYPE_ID, - ESQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, -} from '@kbn/securitysolution-rules'; +import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; import { APP_ID, SECURITY_FEATURE_ID_V2, @@ -29,7 +20,7 @@ import { LISTS_API_ALL, LISTS_API_READ, LISTS_API_SUMMARY, - RULES_FEATURE_ID_V2, + RULES_FEATURE_ID_V3, SECURITY_UI_SHOW, SECURITY_UI_CRUD, INITIALIZE_SECURITY_SOLUTION, @@ -41,26 +32,19 @@ import { USERS_API_READ, RULES_API_ALL, EXCEPTIONS_SUBFEATURE_ALL, + ALERTS_FEATURE_ID, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, + ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE, } from '../../constants'; import type { SecurityFeatureParams } from '../types'; import type { BaseKibanaFeatureConfig } from '../../types'; -const SECURITY_RULE_TYPES = [ - LEGACY_NOTIFICATIONS_ID, - ESQL_RULE_TYPE_ID, - EQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, -]; - -const alertingFeatures = SECURITY_RULE_TYPES.map((ruleTypeId) => ({ - ruleTypeId, - consumers: [SERVER_APP_ID], -})); +const alertingFeatures = [LEGACY_NOTIFICATIONS_ID, ...SECURITY_SOLUTION_RULE_TYPE_IDS].map( + (ruleTypeId) => ({ + ruleTypeId, + consumers: [SERVER_APP_ID], + }) +); export const getSecurityV2BaseKibanaFeature = ({ savedObjects, @@ -107,15 +91,17 @@ export const getSecurityV2BaseKibanaFeature = ({ default: [ // note: overriden by product feature endpointArtifactManagement when enabled { feature: SECURITY_FEATURE_ID_V5, privileges: ['all'] }, - { feature: RULES_FEATURE_ID_V2, privileges: ['all'] }, + { feature: RULES_FEATURE_ID_V3, privileges: ['all'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['all'] }, ], minimal: [ // note: overriden by product feature endpointArtifactManagement when enabled { feature: SECURITY_FEATURE_ID_V5, privileges: ['minimal_all'] }, { - feature: RULES_FEATURE_ID_V2, + feature: RULES_FEATURE_ID_V3, privileges: ['minimal_all', EXCEPTIONS_SUBFEATURE_ALL], }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_all'] }, ], }, app: [APP_ID, CLOUD_POSTURE_APP_ID, CLOUD_DEFEND_APP_ID, 'kibana'], @@ -157,14 +143,16 @@ export const getSecurityV2BaseKibanaFeature = ({ replacedBy: { default: [ { feature: SECURITY_FEATURE_ID_V5, privileges: ['read'] }, - { feature: RULES_FEATURE_ID_V2, privileges: ['read'] }, + { feature: RULES_FEATURE_ID_V3, privileges: ['read'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['read'] }, ], minimal: [ { feature: SECURITY_FEATURE_ID_V5, privileges: ['minimal_read'] }, { - feature: RULES_FEATURE_ID_V2, + feature: RULES_FEATURE_ID_V3, privileges: ['minimal_read'], }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_read'] }, ], }, app: [APP_ID, CLOUD_POSTURE_APP_ID, CLOUD_DEFEND_APP_ID, 'kibana'], @@ -175,6 +163,7 @@ export const getSecurityV2BaseKibanaFeature = ({ LISTS_API_READ, RULES_API_READ, ALERTS_API_READ, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, EXCEPTIONS_API_READ, USERS_API_READ, INITIALIZE_SECURITY_SOLUTION, @@ -194,7 +183,7 @@ export const getSecurityV2BaseKibanaFeature = ({ management: { insightsAndAlerting: ['triggersActions'], }, - ui: [SECURITY_UI_SHOW], + ui: [SECURITY_UI_SHOW, ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE], }, }, }); diff --git a/x-pack/solutions/security/packages/features/src/security/v3_features/kibana_features.ts b/x-pack/solutions/security/packages/features/src/security/v3_features/kibana_features.ts index 760cd61e29991..ede3f58839d26 100644 --- a/x-pack/solutions/security/packages/features/src/security/v3_features/kibana_features.ts +++ b/x-pack/solutions/security/packages/features/src/security/v3_features/kibana_features.ts @@ -8,16 +8,7 @@ import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; -import { - EQL_RULE_TYPE_ID, - ESQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, -} from '@kbn/securitysolution-rules'; +import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; import { APP_ID, SECURITY_FEATURE_ID_V3, @@ -25,7 +16,7 @@ import { CLOUD_POSTURE_APP_ID, SERVER_APP_ID, SECURITY_FEATURE_ID_V5, - RULES_FEATURE_ID_V2, + RULES_FEATURE_ID_V3, LISTS_API_SUMMARY, LISTS_API_READ, LISTS_API_ALL, @@ -40,26 +31,19 @@ import { EXCEPTIONS_API_READ, USERS_API_READ, EXCEPTIONS_SUBFEATURE_ALL, + ALERTS_FEATURE_ID, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, + ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE, } from '../../constants'; import type { SecurityFeatureParams } from '../types'; import type { BaseKibanaFeatureConfig } from '../../types'; -const SECURITY_RULE_TYPES = [ - LEGACY_NOTIFICATIONS_ID, - ESQL_RULE_TYPE_ID, - EQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, -]; - -const alertingFeatures = SECURITY_RULE_TYPES.map((ruleTypeId) => ({ - ruleTypeId, - consumers: [SERVER_APP_ID], -})); +const alertingFeatures = [LEGACY_NOTIFICATIONS_ID, ...SECURITY_SOLUTION_RULE_TYPE_IDS].map( + (ruleTypeId) => ({ + ruleTypeId, + consumers: [SERVER_APP_ID], + }) +); export const getSecurityV3BaseKibanaFeature = ({ savedObjects, @@ -103,14 +87,16 @@ export const getSecurityV3BaseKibanaFeature = ({ replacedBy: { default: [ { feature: SECURITY_FEATURE_ID_V5, privileges: ['all'] }, - { feature: RULES_FEATURE_ID_V2, privileges: ['all'] }, + { feature: RULES_FEATURE_ID_V3, privileges: ['all'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['all'] }, ], minimal: [ { feature: SECURITY_FEATURE_ID_V5, privileges: ['minimal_all'] }, { - feature: RULES_FEATURE_ID_V2, + feature: RULES_FEATURE_ID_V3, privileges: ['minimal_all', EXCEPTIONS_SUBFEATURE_ALL], }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_all'] }, ], }, app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'], @@ -152,14 +138,16 @@ export const getSecurityV3BaseKibanaFeature = ({ replacedBy: { default: [ { feature: SECURITY_FEATURE_ID_V5, privileges: ['read'] }, - { feature: RULES_FEATURE_ID_V2, privileges: ['read'] }, + { feature: RULES_FEATURE_ID_V3, privileges: ['read'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['read'] }, ], minimal: [ { feature: SECURITY_FEATURE_ID_V5, privileges: ['minimal_read'] }, { - feature: RULES_FEATURE_ID_V2, + feature: RULES_FEATURE_ID_V3, privileges: ['minimal_read'], }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_read'] }, ], }, app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'], @@ -170,6 +158,7 @@ export const getSecurityV3BaseKibanaFeature = ({ LISTS_API_READ, RULES_API_READ, ALERTS_API_READ, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, EXCEPTIONS_API_READ, USERS_API_READ, INITIALIZE_SECURITY_SOLUTION, @@ -189,7 +178,7 @@ export const getSecurityV3BaseKibanaFeature = ({ management: { insightsAndAlerting: ['triggersActions'], }, - ui: [SECURITY_UI_SHOW], + ui: [SECURITY_UI_SHOW, ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE], }, }, }); diff --git a/x-pack/solutions/security/packages/features/src/security/v4_features/kibana_features.ts b/x-pack/solutions/security/packages/features/src/security/v4_features/kibana_features.ts index aa4d3823be2bb..c4f509c3a0bd5 100644 --- a/x-pack/solutions/security/packages/features/src/security/v4_features/kibana_features.ts +++ b/x-pack/solutions/security/packages/features/src/security/v4_features/kibana_features.ts @@ -8,16 +8,7 @@ import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; -import { - EQL_RULE_TYPE_ID, - ESQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, -} from '@kbn/securitysolution-rules'; +import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; import { APP_ID, SECURITY_FEATURE_ID_V4, @@ -25,7 +16,7 @@ import { CLOUD_POSTURE_APP_ID, SERVER_APP_ID, SECURITY_FEATURE_ID_V5, - RULES_FEATURE_ID_V2, + RULES_FEATURE_ID_V3, LISTS_API_READ, RULES_API_READ, ALERTS_API_READ, @@ -41,26 +32,19 @@ import { SECURITY_UI_SHOW, CLOUD_DEFEND_APP_ID, EXCEPTIONS_SUBFEATURE_ALL, + ALERTS_FEATURE_ID, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, + ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE, } from '../../constants'; import type { SecurityFeatureParams } from '../types'; import type { BaseKibanaFeatureConfig } from '../../types'; -const SECURITY_RULE_TYPES = [ - LEGACY_NOTIFICATIONS_ID, - ESQL_RULE_TYPE_ID, - EQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - NEW_TERMS_RULE_TYPE_ID, -]; - -const alertingFeatures = SECURITY_RULE_TYPES.map((ruleTypeId) => ({ - ruleTypeId, - consumers: [SERVER_APP_ID], -})); +const alertingFeatures = [LEGACY_NOTIFICATIONS_ID, ...SECURITY_SOLUTION_RULE_TYPE_IDS].map( + (ruleTypeId) => ({ + ruleTypeId, + consumers: [SERVER_APP_ID], + }) +); export const getSecurityV4BaseKibanaFeature = ({ savedObjects, @@ -104,14 +88,16 @@ export const getSecurityV4BaseKibanaFeature = ({ replacedBy: { default: [ { feature: SECURITY_FEATURE_ID_V5, privileges: ['all'] }, - { feature: RULES_FEATURE_ID_V2, privileges: ['all'] }, + { feature: RULES_FEATURE_ID_V3, privileges: ['all'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['all'] }, ], minimal: [ { feature: SECURITY_FEATURE_ID_V5, privileges: ['minimal_all'] }, { - feature: RULES_FEATURE_ID_V2, + feature: RULES_FEATURE_ID_V3, privileges: ['minimal_all', EXCEPTIONS_SUBFEATURE_ALL], }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_all'] }, ], }, app: [APP_ID, CLOUD_POSTURE_APP_ID, CLOUD_DEFEND_APP_ID, 'kibana'], @@ -153,14 +139,16 @@ export const getSecurityV4BaseKibanaFeature = ({ replacedBy: { default: [ { feature: SECURITY_FEATURE_ID_V5, privileges: ['read'] }, - { feature: RULES_FEATURE_ID_V2, privileges: ['read'] }, + { feature: RULES_FEATURE_ID_V3, privileges: ['read'] }, + { feature: ALERTS_FEATURE_ID, privileges: ['read'] }, ], minimal: [ { feature: SECURITY_FEATURE_ID_V5, privileges: ['minimal_read'] }, { - feature: RULES_FEATURE_ID_V2, + feature: RULES_FEATURE_ID_V3, privileges: ['minimal_read'], }, + { feature: ALERTS_FEATURE_ID, privileges: ['minimal_read'] }, ], }, app: [APP_ID, CLOUD_POSTURE_APP_ID, CLOUD_DEFEND_APP_ID, 'kibana'], @@ -171,6 +159,7 @@ export const getSecurityV4BaseKibanaFeature = ({ LISTS_API_READ, RULES_API_READ, ALERTS_API_READ, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, EXCEPTIONS_API_READ, USERS_API_READ, INITIALIZE_SECURITY_SOLUTION, @@ -190,7 +179,7 @@ export const getSecurityV4BaseKibanaFeature = ({ management: { insightsAndAlerting: ['triggersActions'], }, - ui: [SECURITY_UI_SHOW], + ui: [SECURITY_UI_SHOW, ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE], }, }, }); diff --git a/x-pack/solutions/security/packages/features/src/types.ts b/x-pack/solutions/security/packages/features/src/types.ts index 2a0b6ad2c2fe5..9e4d3217cab21 100644 --- a/x-pack/solutions/security/packages/features/src/types.ts +++ b/x-pack/solutions/security/packages/features/src/types.ts @@ -25,6 +25,7 @@ import type { ProductFeatureNotesKey, ProductFeatureRulesKey, } from './product_features_keys'; +import type { AlertsProductFeaturesConfig } from './alerts/types'; export type { ProductFeatureKeyType }; export type ProductFeatureKeys = ProductFeatureKeyType[]; @@ -109,7 +110,26 @@ export interface ProductFeatureParams< productFeatureConfig?: ProductFeaturesConfig; } -export interface ConfigExtensions { +/** Infers the key type from ProductFeatureParams */ +export type ProductFeatureParamsKey

= P extends ProductFeatureParams + ? K + : never; + +/** Infers the sub-feature id type from ProductFeatureParams */ +export type ProductFeatureParamsSubFeatureId

= P extends ProductFeatureParams + ? S + : never; + +/** Infers the key type from a ProductFeaturesConfig-like type (Partial>) */ +export type ProductFeaturesConfigKey = C extends Partial< + Record> +> + ? K + : never; + +export interface ConfigExtensions< + C extends ProductFeaturesConfig & ProductFeatureKeyType> +> { /** The `allVersions` is used to extend all the versions of the feature group */ allVersions: C; /** The `version` object indexed by the feature `id` */ @@ -125,6 +145,7 @@ interface ProductFeatureConfigExtensions { notes: ConfigExtensions; siemMigrations: ConfigExtensions; rules: ConfigExtensions; + alerts: ConfigExtensions; } export type ProductFeaturesConfiguratorExtensions = Partial; diff --git a/x-pack/solutions/security/packages/features/src/utils/product_feature_config.ts b/x-pack/solutions/security/packages/features/src/utils/product_feature_config.ts index 35e002e7d834a..0b9a5b74cd4dc 100644 --- a/x-pack/solutions/security/packages/features/src/utils/product_feature_config.ts +++ b/x-pack/solutions/security/packages/features/src/utils/product_feature_config.ts @@ -6,7 +6,11 @@ */ import { mergeWith, uniq } from 'lodash'; -import type { ProductFeatureKeyType, ProductFeaturesConfig } from '../types'; +import type { + ProductFeatureKeyType, + ProductFeaturesConfig, + ProductFeaturesConfigKey, +} from '../types'; /** * Custom merge function for product feature configs. To be used with `mergeWith`. @@ -27,15 +31,16 @@ export const featureConfigMerger = (objValue: unknown, srcValue: unknown) => { * Extends multiple ProductFeaturesConfig objects into a single one. * It merges arrays by removing duplicates and keeps the rest of the properties as is. * It does not mutate the original objects. + * Accepts any config type C whose key type extends ProductFeatureKeyType (inferred via ProductFeaturesConfigKey). * * @param productFeatureConfigs - The product feature configs to merge * @returns A single extended ProductFeaturesConfig object */ export const extendProductFeatureConfigs = < - K extends ProductFeatureKeyType, + C extends ProductFeaturesConfig & ProductFeatureKeyType>, S extends string = string >( - ...productFeatureConfigs: Array> -): ProductFeaturesConfig => { + ...productFeatureConfigs: C[] +): ProductFeaturesConfig => { return mergeWith({}, ...productFeatureConfigs, featureConfigMerger); }; diff --git a/x-pack/solutions/security/packages/features/tsconfig.json b/x-pack/solutions/security/packages/features/tsconfig.json index d656da87be413..782ed78ae27fd 100644 --- a/x-pack/solutions/security/packages/features/tsconfig.json +++ b/x-pack/solutions/security/packages/features/tsconfig.json @@ -18,6 +18,7 @@ "@kbn/securitysolution-rules", "@kbn/securitysolution-list-constants", "@kbn/elastic-assistant-common", + "@kbn/data-views-plugin", ], "exclude": ["target/**/*"] } diff --git a/x-pack/solutions/security/packages/test-api-clients/supertest/detections.gen.ts b/x-pack/solutions/security/packages/test-api-clients/supertest/detections.gen.ts index baeae357e92a8..7d9ad5a0312e9 100644 --- a/x-pack/solutions/security/packages/test-api-clients/supertest/detections.gen.ts +++ b/x-pack/solutions/security/packages/test-api-clients/supertest/detections.gen.ts @@ -610,11 +610,11 @@ export function SecuritySolutionApiProvider({ getService }: FtrProviderContext) return { ...securitySolutionApiServiceFactory(supertestService), - withUser: (user: { username: string; password: string }) => { + withUser: (user: { username: string; password?: string }) => { const kbnUrl = formatUrl({ ...config.get('servers.kibana'), auth: false }); return securitySolutionApiServiceFactory( - supertest_.agent(kbnUrl).auth(user.username, user.password) + supertest_.agent(kbnUrl).auth(user.username, user.password ?? 'changeme') ); }, }; diff --git a/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_exceptions.gen.ts b/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_exceptions.gen.ts index 4c0815cd3a430..40764f2530d3b 100644 --- a/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_exceptions.gen.ts +++ b/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_exceptions.gen.ts @@ -105,11 +105,11 @@ export function SecuritySolutionApiProvider({ getService }: FtrProviderContext) return { ...securitySolutionApiServiceFactory(supertestService), - withUser: (user: { username: string; password: string }) => { + withUser: (user: { username: string; password?: string }) => { const kbnUrl = formatUrl({ ...config.get('servers.kibana'), auth: false }); return securitySolutionApiServiceFactory( - supertest_.agent(kbnUrl).auth(user.username, user.password) + supertest_.agent(kbnUrl).auth(user.username, user.password ?? 'changeme') ); }, }; diff --git a/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_management.gen.ts b/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_management.gen.ts index 2ca141919d567..5c9c5338e72a2 100644 --- a/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_management.gen.ts +++ b/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_management.gen.ts @@ -360,11 +360,11 @@ export function SecuritySolutionApiProvider({ getService }: FtrProviderContext) return { ...securitySolutionApiServiceFactory(supertestService), - withUser: (user: { username: string; password: string }) => { + withUser: (user: { username: string; password?: string }) => { const kbnUrl = formatUrl({ ...config.get('servers.kibana'), auth: false }); return securitySolutionApiServiceFactory( - supertest_.agent(kbnUrl).auth(user.username, user.password) + supertest_.agent(kbnUrl).auth(user.username, user.password ?? 'changeme') ); }, }; diff --git a/x-pack/solutions/security/packages/test-api-clients/supertest/entity_analytics.gen.ts b/x-pack/solutions/security/packages/test-api-clients/supertest/entity_analytics.gen.ts index e67c7597e102d..f94c196193d97 100644 --- a/x-pack/solutions/security/packages/test-api-clients/supertest/entity_analytics.gen.ts +++ b/x-pack/solutions/security/packages/test-api-clients/supertest/entity_analytics.gen.ts @@ -850,11 +850,11 @@ export function SecuritySolutionApiProvider({ getService }: FtrProviderContext) return { ...securitySolutionApiServiceFactory(supertestService), - withUser: (user: { username: string; password: string }) => { + withUser: (user: { username: string; password?: string }) => { const kbnUrl = formatUrl({ ...config.get('servers.kibana'), auth: false }); return securitySolutionApiServiceFactory( - supertest_.agent(kbnUrl).auth(user.username, user.password) + supertest_.agent(kbnUrl).auth(user.username, user.password ?? 'changeme') ); }, }; diff --git a/x-pack/solutions/security/packages/test-api-clients/supertest/exceptions.gen.ts b/x-pack/solutions/security/packages/test-api-clients/supertest/exceptions.gen.ts index ed1a3527ea291..c2af66348f9e4 100644 --- a/x-pack/solutions/security/packages/test-api-clients/supertest/exceptions.gen.ts +++ b/x-pack/solutions/security/packages/test-api-clients/supertest/exceptions.gen.ts @@ -251,11 +251,11 @@ export function SecuritySolutionApiProvider({ getService }: FtrProviderContext) return { ...securitySolutionApiServiceFactory(supertestService), - withUser: (user: { username: string; password: string }) => { + withUser: (user: { username: string; password?: string }) => { const kbnUrl = formatUrl({ ...config.get('servers.kibana'), auth: false }); return securitySolutionApiServiceFactory( - supertest_.agent(kbnUrl).auth(user.username, user.password) + supertest_.agent(kbnUrl).auth(user.username, user.password ?? 'changeme') ); }, }; diff --git a/x-pack/solutions/security/packages/test-api-clients/supertest/lists.gen.ts b/x-pack/solutions/security/packages/test-api-clients/supertest/lists.gen.ts index 061780e565d89..4be0818f8a5fc 100644 --- a/x-pack/solutions/security/packages/test-api-clients/supertest/lists.gen.ts +++ b/x-pack/solutions/security/packages/test-api-clients/supertest/lists.gen.ts @@ -257,11 +257,11 @@ export function SecuritySolutionApiProvider({ getService }: FtrProviderContext) return { ...securitySolutionApiServiceFactory(supertestService), - withUser: (user: { username: string; password: string }) => { + withUser: (user: { username: string; password?: string }) => { const kbnUrl = formatUrl({ ...config.get('servers.kibana'), auth: false }); return securitySolutionApiServiceFactory( - supertest_.agent(kbnUrl).auth(user.username, user.password) + supertest_.agent(kbnUrl).auth(user.username, user.password ?? 'changeme') ); }, }; diff --git a/x-pack/solutions/security/packages/test-api-clients/supertest/osquery.gen.ts b/x-pack/solutions/security/packages/test-api-clients/supertest/osquery.gen.ts index 08c87c0e835bd..ac521325857da 100644 --- a/x-pack/solutions/security/packages/test-api-clients/supertest/osquery.gen.ts +++ b/x-pack/solutions/security/packages/test-api-clients/supertest/osquery.gen.ts @@ -432,11 +432,11 @@ export function SecuritySolutionApiProvider({ getService }: FtrProviderContext) return { ...securitySolutionApiServiceFactory(supertestService), - withUser: (user: { username: string; password: string }) => { + withUser: (user: { username: string; password?: string }) => { const kbnUrl = formatUrl({ ...config.get('servers.kibana'), auth: false }); return securitySolutionApiServiceFactory( - supertest_.agent(kbnUrl).auth(user.username, user.password) + supertest_.agent(kbnUrl).auth(user.username, user.password ?? 'changeme') ); }, }; diff --git a/x-pack/solutions/security/packages/test-api-clients/supertest/timelines.gen.ts b/x-pack/solutions/security/packages/test-api-clients/supertest/timelines.gen.ts index 73ab34060630e..181d26edaf167 100644 --- a/x-pack/solutions/security/packages/test-api-clients/supertest/timelines.gen.ts +++ b/x-pack/solutions/security/packages/test-api-clients/supertest/timelines.gen.ts @@ -247,11 +247,11 @@ export function SecuritySolutionApiProvider({ getService }: FtrProviderContext) return { ...securitySolutionApiServiceFactory(supertestService), - withUser: (user: { username: string; password: string }) => { + withUser: (user: { username: string; password?: string }) => { const kbnUrl = formatUrl({ ...config.get('servers.kibana'), auth: false }); return securitySolutionApiServiceFactory( - supertest_.agent(kbnUrl).auth(user.username, user.password) + supertest_.agent(kbnUrl).auth(user.username, user.password ?? 'changeme') ); }, }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/privileges/get_missing_privileges.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/privileges/get_missing_privileges.ts index 4cb0085ae391f..c9d6ec0573483 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/privileges/get_missing_privileges.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/privileges/get_missing_privileges.ts @@ -18,6 +18,7 @@ import type { SecurityHasPrivilegesResponse, SecurityIndexPrivilege, } from '@elastic/elasticsearch/lib/api/types'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; import { getScheduledIndexPattern } from '../../../lib/attack_discovery/persistence/get_scheduled_index_pattern'; @@ -53,7 +54,7 @@ export const getMissingIndexPrivilegesInternalRoute = ( path: ATTACK_DISCOVERY_INTERNAL_MISSING_PRIVILEGES, security: { authz: { - requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL], + requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ], }, }, }) diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/find_attack_discoveries.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/find_attack_discoveries.ts index ab19918cd003e..87404cff20a5c 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/find_attack_discoveries.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/find_attack_discoveries.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; import { transformError } from '@kbn/securitysolution-es-utils'; import { @@ -30,7 +31,7 @@ export const findAttackDiscoveriesRoute = ( path: ATTACK_DISCOVERY_FIND, security: { authz: { - requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL], + requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ], }, }, }) diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/get_attack_discovery_generation.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/get_attack_discovery_generation.ts index 79a6f43ab394a..165aa7498870d 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/get_attack_discovery_generation.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/get_attack_discovery_generation.ts @@ -13,6 +13,7 @@ import { GetAttackDiscoveryGenerationRequestParams, GetAttackDiscoveryGenerationRequestQuery, } from '@kbn/elastic-assistant-common'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; import { transformError } from '@kbn/securitysolution-es-utils'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; @@ -33,7 +34,7 @@ export const getAttackDiscoveryGenerationRoute = ( path: ATTACK_DISCOVERY_GENERATIONS_BY_ID, security: { authz: { - requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL], + requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ], }, }, }) diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/get_attack_discovery_generations.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/get_attack_discovery_generations.ts index c25e2c0957ffd..f26190c260a28 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/get_attack_discovery_generations.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/get/get_attack_discovery_generations.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; import { transformError } from '@kbn/securitysolution-es-utils'; import { @@ -29,7 +30,7 @@ export const getAttackDiscoveryGenerationsRoute = ( path: ATTACK_DISCOVERY_GENERATIONS, security: { authz: { - requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL], + requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ], }, }, }) diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_bulk.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_bulk.ts index bbf0bfd9b8baf..1c9575f00dcc8 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_bulk.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_bulk.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; import { transformError } from '@kbn/securitysolution-es-utils'; import { @@ -30,7 +31,7 @@ export const postAttackDiscoveryBulkRoute = ( path: ATTACK_DISCOVERY_BULK, security: { authz: { - requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL], + requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ], }, }, }) diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_generate.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_generate.ts index b9c53aa4effd4..b26eb66a7b723 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_generate.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_generate.ts @@ -14,6 +14,7 @@ import { getAttackDiscoveryLoadingMessage, } from '@kbn/elastic-assistant-common'; import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; import { transformError } from '@kbn/securitysolution-es-utils'; import { v4 as uuidv4 } from 'uuid'; @@ -43,7 +44,7 @@ export const postAttackDiscoveryGenerateRoute = ( path: ATTACK_DISCOVERY_GENERATE, security: { authz: { - requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL], + requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ], }, }, options: { diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_generations_dismiss.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_generations_dismiss.ts index 09cd4df58823c..7fc974f5f67ee 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_generations_dismiss.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/post_attack_discovery_generations_dismiss.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; import { transformError } from '@kbn/securitysolution-es-utils'; import { @@ -31,7 +32,7 @@ export const postAttackDiscoveryGenerationsDismissRoute = ( path: ATTACK_DISCOVERY_GENERATIONS_BY_ID_DISMISS, security: { authz: { - requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL], + requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ], }, }, }) diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/delete/delete.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/delete/delete.ts index e2cfab542b3da..3f82660699ad3 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/delete/delete.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/delete/delete.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL, ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, @@ -35,6 +36,7 @@ export const deleteAttackDiscoverySchedulesRoute = ( requiredPrivileges: [ ATTACK_DISCOVERY_API_ACTION_ALL, ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, + ALERTS_API_READ, ], }, }, diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/get/find.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/get/find.ts index 188d7bbc53926..a431fe18a47a2 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/get/find.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/get/find.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; import { transformError } from '@kbn/securitysolution-es-utils'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; @@ -33,7 +34,7 @@ export const findAttackDiscoverySchedulesRoute = ( path: ATTACK_DISCOVERY_SCHEDULES_FIND, security: { authz: { - requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL], + requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ], }, }, }) diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/get/get.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/get/get.ts index 924340829a51f..5e61ea9599163 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/get/get.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/get/get.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; import { transformError } from '@kbn/securitysolution-es-utils'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; @@ -30,7 +31,7 @@ export const getAttackDiscoverySchedulesRoute = ( path: ATTACK_DISCOVERY_SCHEDULES_BY_ID, security: { authz: { - requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL], + requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ], }, }, }) diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/create.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/create.ts index c7afabecfdf18..472da7bad30ec 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/create.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/create.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL, ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, @@ -37,6 +38,7 @@ export const createAttackDiscoverySchedulesRoute = ( requiredPrivileges: [ ATTACK_DISCOVERY_API_ACTION_ALL, ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, + ALERTS_API_READ, ], }, }, diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/disable.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/disable.ts index 5b9ddcb7fb851..027111bea5eb3 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/disable.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/disable.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL, ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, @@ -35,6 +36,7 @@ export const disableAttackDiscoverySchedulesRoute = ( requiredPrivileges: [ ATTACK_DISCOVERY_API_ACTION_ALL, ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, + ALERTS_API_READ, ], }, }, diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/enable.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/enable.ts index df532bc9d8550..752940489d9a6 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/enable.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/post/enable.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL, ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, @@ -35,6 +36,7 @@ export const enableAttackDiscoverySchedulesRoute = ( requiredPrivileges: [ ATTACK_DISCOVERY_API_ACTION_ALL, ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, + ALERTS_API_READ, ], }, }, diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/put/update.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/put/update.ts index 9b479b785fa5b..88493f3b4efd4 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/put/update.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/schedules/public/put/update.ts @@ -6,6 +6,7 @@ */ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY_API_ACTION_ALL, ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, @@ -38,6 +39,7 @@ export const updateAttackDiscoverySchedulesRoute = ( requiredPrivileges: [ ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, ATTACK_DISCOVERY_API_ACTION_ALL, + ALERTS_API_READ, ], }, }, diff --git a/x-pack/solutions/security/plugins/security_solution/common/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/constants.ts index d6a768953cfca..a7545e87145d2 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/constants.ts @@ -35,6 +35,7 @@ export const NOTES_FEATURE_ID = 'securitySolutionNotes' as const; export const SERVER_APP_ID = 'siem' as const; export const SECURITY_FEATURE_ID = SECURITY_FEATURE_ID_V5; export const RULES_FEATURE_ID = RULES_FEATURE_LATEST; +export { ALERTS_FEATURE_ID } from '@kbn/security-solution-features/constants'; export const APP_NAME = 'Security' as const; export const APP_ICON_SOLUTION = 'logoSecurity' as const; export const APP_PATH = `/app/security` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/links.test.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/links.test.ts index fb5409d1f3e58..b826f1c41156d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/links.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/links.test.ts @@ -7,12 +7,12 @@ import { ATTACK_DISCOVERY_FEATURE_ID } from '../../common/constants'; import { links } from './links'; -import { RULES_UI_READ_PRIVILEGE } from '@kbn/security-solution-features/constants'; +import { ALERTS_UI_READ_PRIVILEGE } from '@kbn/security-solution-features/constants'; describe('links', () => { it('for serverless, it specifies capabilities as an AND condition, via a nested array', () => { expect(links.capabilities).toEqual([ - [RULES_UI_READ_PRIVILEGE, `${ATTACK_DISCOVERY_FEATURE_ID}.attack-discovery`], + [ALERTS_UI_READ_PRIVILEGE, `${ATTACK_DISCOVERY_FEATURE_ID}.attack-discovery`], ]); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/links.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/links.ts index 69e06e41bca36..b0b678ebcfdcc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/links.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; -import { RULES_UI_READ_PRIVILEGE } from '@kbn/security-solution-features/constants'; +import { ALERTS_UI_READ_PRIVILEGE } from '@kbn/security-solution-features/constants'; import { ATTACK_DISCOVERY } from '../app/translations'; import { ATTACK_DISCOVERY_FEATURE_ID, @@ -17,7 +17,7 @@ import { import type { LinkItem } from '../common/links/types'; export const links: LinkItem = { - capabilities: [[RULES_UI_READ_PRIVILEGE, `${ATTACK_DISCOVERY_FEATURE_ID}.attack-discovery`]], // This is an AND condition via the nested array + capabilities: [[ALERTS_UI_READ_PRIVILEGE, `${ATTACK_DISCOVERY_FEATURE_ID}.attack-discovery`]], // This is an AND condition via the nested array globalNavPosition: 4, globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.attackDiscovery', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/summary/selected_actions/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/summary/selected_actions/index.test.tsx index 6c57382e47176..ca438a8076842 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/summary/selected_actions/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/summary/selected_actions/index.test.tsx @@ -52,6 +52,15 @@ jest.mock('../../attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assi }), })); +jest.mock( + '../../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', + () => ({ + useAlertsPrivileges: () => ({ + hasAlertsUpdate: true, + }), + }) +); + describe('SelectedActions', () => { const defaultProps = { refetchFindAttackDiscoveries: jest.fn(), diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.test.tsx index 2d7f10b831855..92174e3be07e9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.test.tsx @@ -23,6 +23,7 @@ import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; import { getMockAttackDiscoveryAlerts } from '../../mock/mock_attack_discovery_alerts'; import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; import { useAgentBuilderAvailability } from '../../../../agent_builder/hooks/use_agent_builder_availability'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { TakeAction } from '.'; const defaultAgentBuilderAvailability = { @@ -73,6 +74,10 @@ jest.mock('../../utils/is_attack_discovery_alert', () => ({ ad?.alertWorkflowStatus !== undefined, })); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); + +const mockUseAlertsPrivileges = useAlertsPrivileges as jest.Mock; + /** helper function to open the popover */ const openPopover = () => fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]); @@ -122,6 +127,8 @@ describe('TakeAction', () => { mockUseAssistantAvailability.mockReturnValue({ hasSearchAILakeConfigurations: false, // EASE is not configured }); + + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: true }); }); it('renders the Add to new case action', () => { @@ -610,4 +617,39 @@ describe('TakeAction', () => { expect(viewInAiAssistantButton).toBeDisabled(); }); }); + + describe('when the user does not have alert edit privileges', () => { + beforeEach(() => { + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: false }); + }); + + it('does not render mark as open action', () => { + const alert = { ...mockAttackDiscovery, alertWorkflowStatus: 'closed', id: 'id1' }; + + render( + + + + ); + + openPopover(); + + expect(screen.queryByTestId('markAsOpen')).not.toBeInTheDocument(); + }); + + it('does not render mark as closed/acknowledged action', () => { + const alert = { ...mockAttackDiscovery, alertWorkflowStatus: 'open', id: 'id1' }; + + render( + + + + ); + + openPopover(); + + expect(screen.queryByTestId('markAsAcknowledged')).not.toBeInTheDocument(); + expect(screen.queryByTestId('markAsClosed')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx index 1334faaae46f8..0d696512d34b7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx @@ -37,6 +37,7 @@ import { useUpdateAlertsStatus } from './use_update_alerts_status'; import { isAttackDiscoveryAlert } from '../../utils/is_attack_discovery_alert'; import { useAgentBuilderAvailability } from '../../../../agent_builder/hooks/use_agent_builder_availability'; import { useAttackDiscoveryAttachment } from '../use_attack_discovery_attachment'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; interface Props { attackDiscoveries: AttackDiscovery[] | AttackDiscoveryAlert[]; @@ -77,6 +78,8 @@ const TakeActionComponent: React.FC = ({ canUserCreateAndReadCases, }); + const { hasAlertsUpdate } = useAlertsPrivileges(); + // boilerplate for the take action popover: const takeActionContextMenuPopoverId = useGeneratedHtmlId({ prefix: 'takeActionContextMenuPopover', @@ -325,44 +328,47 @@ const TakeActionComponent: React.FC = ({ const isAcknowledged = isAlert && firstAttackDiscovery.alertWorkflowStatus === 'acknowledged'; const isClosed = isAlert && firstAttackDiscovery.alertWorkflowStatus === 'closed'; - const markAsOpenItem = !isOpen - ? [ - onUpdateWorkflowStatus('open')} - > - {i18n.MARK_AS_OPEN} - , - ] - : []; - - const markAsAcknowledgedItem = !isAcknowledged - ? [ - onUpdateWorkflowStatus('acknowledged')} - > - {i18n.MARK_AS_ACKNOWLEDGED} - , - ] - : []; - - const markAsClosedItem = !isClosed - ? [ - onUpdateWorkflowStatus('closed')} - > - {i18n.MARK_AS_CLOSED} - , - ] - : []; + const markAsOpenItem = + !isOpen && hasAlertsUpdate + ? [ + onUpdateWorkflowStatus('open')} + > + {i18n.MARK_AS_OPEN} + , + ] + : []; + + const markAsAcknowledgedItem = + !isAcknowledged && hasAlertsUpdate + ? [ + onUpdateWorkflowStatus('acknowledged')} + > + {i18n.MARK_AS_ACKNOWLEDGED} + , + ] + : []; + + const markAsClosedItem = + !isClosed && hasAlertsUpdate + ? [ + onUpdateWorkflowStatus('closed')} + > + {i18n.MARK_AS_CLOSED} + , + ] + : []; return [...markAsOpenItem, ...markAsAcknowledgedItem, ...markAsClosedItem, ...items].flat(); - }, [attackDiscoveries, items, onUpdateWorkflowStatus]); + }, [attackDiscoveries, items, onUpdateWorkflowStatus, hasAlertsUpdate]); const onCloseOrCancel = useCallback(() => { setPendingAction(null); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/pages/use_fetch_alert_data.ts b/x-pack/solutions/security/plugins/security_solution/public/cases/pages/use_fetch_alert_data.ts index ec0d47b570c1a..76d80a0278988 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cases/pages/use_fetch_alert_data.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/pages/use_fetch_alert_data.ts @@ -10,6 +10,7 @@ import type { Ecs } from '@kbn/cases-plugin/common'; import { PageScope } from '../../data_view_manager/constants'; import { useSourcererDataView } from '../../sourcerer/containers'; import { useQueryAlerts } from '../../detections/containers/detection_engine/alerts/use_query'; +import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { ALERTS_QUERY_NAMES } from '../../detections/containers/detection_engine/alerts/constants'; import type { SignalHit } from '../../common/utils/alerts'; import { buildAlertsQuery, formatAlertToEcsSignal } from '../../common/utils/alerts'; @@ -17,6 +18,7 @@ import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experime import { useSelectedPatterns } from '../../data_view_manager/hooks/use_selected_patterns'; export const useFetchAlertData = (alertIds: string[]): [boolean, Record] => { + const { hasAlertsRead } = useAlertsPrivileges(); const { selectedPatterns: oldSelectedPatterns } = useSourcererDataView(PageScope.alerts); const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); @@ -32,6 +34,7 @@ export const useFetchAlertData = (alertIds: string[]): [boolean, Record ( jest.mock( '../../../detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline', () => ({ - useAddBulkToTimelineAction: jest.fn(), + useAddBulkToTimelineAction: jest.fn().mockReturnValue([]), }) ); @@ -112,7 +112,6 @@ describe('EventsQueryTabBody', () => { (useUserPrivileges as jest.Mock).mockReturnValue({ notesPrivileges: { read: true }, }); - jest.clearAllMocks(); }); it('renders EventsViewer', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx index bc927f05af39a..b11c4296cc8f7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx @@ -166,13 +166,13 @@ const EventsQueryTabBodyComponent: React.FC = [additionalFilters, showExternalAlerts] ); - const addBulkToTimelineAction = useAddBulkToTimelineAction({ + const addBulkToTimelineActions = useAddBulkToTimelineAction({ localFilters: composedPageFilters, tableId, from: startDate, to: endDate, scopeId: PageScope.default, - }) as CustomBulkAction; + }) as CustomBulkAction[]; const caseEventsBulkActions = useBulkAddEventsToCaseActions({ clearSelection: () => dispatch(dataTableActions.clearSelected({ id: tableId })), @@ -181,9 +181,9 @@ const EventsQueryTabBodyComponent: React.FC = const bulkActions = useMemo(() => { return { alertStatusActions: false, - customBulkActions: [addBulkToTimelineAction, ...caseEventsBulkActions], + customBulkActions: [...addBulkToTimelineActions, ...caseEventsBulkActions], }; - }, [addBulkToTimelineAction, caseEventsBulkActions]); + }, [addBulkToTimelineActions, caseEventsBulkActions]); return ( <> diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/rule_name/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/rule_name/index.tsx deleted file mode 100644 index ebd0e0842dd12..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/rule_name/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiLink } from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import React, { useCallback, useMemo } from 'react'; -import type { CoreStart } from '@kbn/core/public'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; - -interface RuleNameProps { - name: string; - id: string; - appId: string; -} - -const appendSearch = (search?: string) => - isEmpty(search) ? '' : `${search?.startsWith('?') ? search : `?${search}`}`; - -const RuleNameComponents = ({ name, id, appId }: RuleNameProps) => { - const { navigateToApp, getUrlForApp } = useKibana().services.application; - - const hrefRuleDetails = useMemo( - () => - getUrlForApp(appId, { - deepLinkId: 'rules', - path: `/id/${id}${appendSearch(window.location.search)}`, - }), - [getUrlForApp, id, appId] - ); - const goToRuleDetails = useCallback( - (ev: React.SyntheticEvent) => { - ev.preventDefault(); - navigateToApp(appId, { - deepLinkId: 'rules', - path: `/id/${id}${appendSearch(window.location.search)}`, - }); - }, - [navigateToApp, id, appId] - ); - return ( - // eslint-disable-next-line @elastic/eui/href-or-on-click - - {name} - - ); -}; - -export const RuleName = React.memo(RuleNameComponents); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx index e94c1df608768..db7686097f036 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx @@ -18,6 +18,13 @@ import type { EventsViewerProps } from '../events_viewer'; jest.mock('../../lib/kibana'); jest.mock('../../utils/normalize_time_range'); +jest.mock( + '../../../detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline', + () => ({ + useAddBulkToTimelineAction: jest.fn().mockReturnValue([]), + }) +); + const startDate = '2022-03-22T22:10:56.794Z'; const endDate = '2022-03-21T22:10:56.791Z'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/sessions_viewer/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/sessions_viewer/index.tsx index 0e214d8df597d..dfed025904988 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/sessions_viewer/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/sessions_viewer/index.tsx @@ -127,7 +127,7 @@ const SessionsViewComponent: React.FC = ({ [ACTION_BUTTON_COUNT] ); - const addBulkToTimelineAction = useAddBulkToTimelineAction({ + const addBulkToTimelineActions = useAddBulkToTimelineAction({ localFilters: sessionsFilter, tableId, from: startDate, @@ -138,9 +138,9 @@ const SessionsViewComponent: React.FC = ({ const bulkActions = useMemo(() => { return { alertStatusActions: false, - customBulkActions: [addBulkToTimelineAction], + customBulkActions: addBulkToTimelineActions, } as BulkActionsProp; - }, [addBulkToTimelineAction]); + }, [addBulkToTimelineActions]); const unit = (c: number) => c > 1 ? i18n.TOTAL_COUNT_OF_SESSIONS : i18n.SINGLE_COUNT_OF_SESSIONS; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_actions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_actions.test.tsx index 40ef6d6070463..21016013bcd2e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_actions.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_actions.test.tsx @@ -39,7 +39,7 @@ function renderAlertBulkActions(props?: Partial) describe('AlertBulkActionsComponent', () => { beforeEach(() => { - (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsUpdate: true }); }); it('it renders', async () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.test.tsx index af09919cce632..cd037ff7b1794 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.test.tsx @@ -9,18 +9,17 @@ import { renderHook } from '@testing-library/react'; import type { BulkActionsProps } from './use_bulk_action_items'; import { useBulkActionItems } from './use_bulk_action_items'; import { useAppToasts } from '../../../hooks/use_app_toasts'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; jest.mock('../../../hooks/use_app_toasts'); jest.mock('../../../lib/kibana'); -jest.mock( - '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', - () => ({ - useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true }), - }) -); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../../hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn(), })); + +const mockUseAlertsPrivileges = useAlertsPrivileges as jest.Mock; + (useAppToasts as jest.Mock).mockReturnValue({ addSuccess: jest.fn(), addError: jest.fn(), @@ -38,18 +37,25 @@ function renderUseBulkActionItems(props?: Partial) { } describe('useBulkActionItems', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: true }); + }); + it('should return "mark as open" option by default', () => { const { result } = renderUseBulkActionItems(); expect( result.current.items.find((item) => item['data-test-subj'] === 'open-alert-status') ).not.toBeUndefined(); }); + it('should return "mark as acknowledged" option by default', () => { const { result } = renderUseBulkActionItems(); expect( result.current.items.find((item) => item['data-test-subj'] === 'acknowledged-alert-status') ).not.toBeUndefined(); }); + it('should return "mark as closed" option by default', () => { const { result } = renderUseBulkActionItems(); expect( @@ -58,4 +64,22 @@ describe('useBulkActionItems', () => { ) ).not.toBeUndefined(); }); + + it('should not return alert status actions when user does not have alerts privileges', () => { + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: false }); + + const { result } = renderUseBulkActionItems(); + + expect( + result.current.items.find((item) => item['data-test-subj'] === 'open-alert-status') + ).toBeUndefined(); + expect( + result.current.items.find((item) => item['data-test-subj'] === 'acknowledged-alert-status') + ).toBeUndefined(); + expect( + result.current.items.find( + (item) => item['data-test-subj'] === 'alert-close-context-menu-item' + ) + ).toBeUndefined(); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx index d7febb8066dae..4a82647a53759 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx @@ -24,6 +24,7 @@ import type { AlertWorkflowStatus } from '../../../types'; import type { OnUpdateAlertStatusError, OnUpdateAlertStatusSuccess } from './types'; import { useAlertCloseInfoModal } from '../../../../detections/hooks/use_alert_close_info_modal'; import { useBulkAlertClosingReasonItems } from './use_bulk_alert_closing_reason_items'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; export interface BulkActionsProps { eventIds: string[]; @@ -51,6 +52,7 @@ export const useBulkActionItems = ({ const { addSuccess, addError, addWarning } = useAppToasts(); const { startTransaction } = useStartTransaction(); const { promptAlertCloseConfirmation } = useAlertCloseInfoModal(); + const { hasAlertsUpdate } = useAlertsPrivileges(); const onAlertStatusUpdateSuccess = useCallback( (updated: number, conflicts: number, newStatus: AlertWorkflowStatus) => { @@ -160,7 +162,7 @@ export const useBulkActionItems = ({ const items = useMemo(() => { const actionItems: AlertTableContextMenuItem[] = []; - if (showAlertStatusActions) { + if (showAlertStatusActions && hasAlertsUpdate) { if (currentStatus !== FILTER_OPEN) { actionItems.push({ key: 'open', @@ -204,6 +206,7 @@ export const useBulkActionItems = ({ return [...actionItems, ...additionalItems]; }, [ + hasAlertsUpdate, alertClosingReasonItem, currentStatus, customBulkActions, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx index fa8e8a39f6625..9d42a3fcc850e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx @@ -73,7 +73,7 @@ describe('useBulkAlertAssigneesItems', () => { isLoading: false, data: mockUserProfiles, }); - (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsUpdate: true }); (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); }); @@ -231,7 +231,7 @@ describe('useBulkAlertAssigneesItems', () => { }); it('should return 0 items for the VIEWER role', () => { - (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsUpdate: false }); const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { wrapper: TestProviders, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx index 0bef8950aeb16..1ee1c06cd0794 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx @@ -43,7 +43,7 @@ export const useBulkAlertAssigneesItems = ({ }: UseBulkAlertAssigneesItemsProps) => { const isPlatinumPlus = useLicense().isPlatinumPlus(); - const { hasIndexWrite } = useAlertsPrivileges(); + const { hasAlertsUpdate } = useAlertsPrivileges(); const setAlertAssignees = useSetAlertAssignees(); const handleOnAlertAssigneesSubmit = useCallback< @@ -86,7 +86,7 @@ export const useBulkAlertAssigneesItems = ({ const alertAssigneesItems = useMemo( () => - hasIndexWrite && isPlatinumPlus + hasAlertsUpdate && isPlatinumPlus ? [ { key: 'manage-alert-assignees', @@ -108,7 +108,7 @@ export const useBulkAlertAssigneesItems = ({ }, ] : [], - [alertAssignments, hasIndexWrite, isPlatinumPlus, onRemoveAllAssignees] + [alertAssignments, hasAlertsUpdate, isPlatinumPlus, onRemoveAllAssignees] ); const TitleContent = useMemo( @@ -145,7 +145,7 @@ export const useBulkAlertAssigneesItems = ({ const alertAssigneesPanels: UseBulkAlertAssigneesPanel[] = useMemo( () => - hasIndexWrite && isPlatinumPlus + hasAlertsUpdate && isPlatinumPlus ? [ { id: 2, @@ -156,7 +156,7 @@ export const useBulkAlertAssigneesItems = ({ }, ] : [], - [TitleContent, hasIndexWrite, isPlatinumPlus, renderContent] + [TitleContent, hasAlertsUpdate, isPlatinumPlus, renderContent] ); return useMemo(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.test.tsx index 0dd1aea4880be..721dc2da115f5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.test.tsx @@ -18,7 +18,7 @@ jest.mock('../../../lib/kibana'); jest.mock( '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', () => ({ - useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true }), + useAlertsPrivileges: jest.fn().mockReturnValue({ hasAlertsUpdate: true }), }) ); jest.mock('../../../hooks/use_experimental_features', () => ({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.tsx index e869ef159e644..b09fab34a0e3f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.tsx @@ -41,10 +41,10 @@ export const useBulkAlertClosingReasonItems = ({ onSubmitCloseReason, buttonLabel, }: UseBulkAlertClosingReasonItemsProps = {}) => { - const { hasIndexWrite } = useAlertsPrivileges(); + const { hasAlertsUpdate } = useAlertsPrivileges(); const item = useMemo( () => - hasIndexWrite + hasAlertsUpdate ? ({ key: 'close-alert-with-reason', 'data-test-subj': 'alert-close-context-menu-item', @@ -52,7 +52,7 @@ export const useBulkAlertClosingReasonItems = ({ panel: ALERT_CLOSING_REASON_PANEL_ID, } as BulkActionsConfig) : undefined, - [hasIndexWrite] + [hasAlertsUpdate] ); const getRenderContent = useCallback( @@ -81,7 +81,7 @@ export const useBulkAlertClosingReasonItems = ({ const panels = useMemo( () => - hasIndexWrite + hasAlertsUpdate ? ([ { id: ALERT_CLOSING_REASON_PANEL_ID, @@ -90,7 +90,7 @@ export const useBulkAlertClosingReasonItems = ({ }, ] as ContentPanelConfig[]) : [], - [hasIndexWrite, getRenderContent, onSubmitCloseReason, buttonLabel] + [hasAlertsUpdate, getRenderContent, onSubmitCloseReason, buttonLabel] ); /** @@ -103,7 +103,7 @@ export const useBulkAlertClosingReasonItems = ({ }: { onSubmitCloseReason?: UseBulkAlertClosingReasonItemsProps['onSubmitCloseReason']; }) => - hasIndexWrite + hasAlertsUpdate ? ([ { id: ALERT_CLOSING_REASON_PANEL_ID, @@ -115,7 +115,7 @@ export const useBulkAlertClosingReasonItems = ({ }, ] as ContentPanelConfig[]) : [], - [getRenderContent, hasIndexWrite, buttonLabel] + [getRenderContent, hasAlertsUpdate, buttonLabel] ); return useMemo( diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.test.tsx index b1dfc10ea6947..319fe9d2f0618 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.test.tsx @@ -15,15 +15,13 @@ import type { import { useBulkAlertTagsItems } from './use_bulk_alert_tags_items'; import { useSetAlertTags } from './use_set_alert_tags'; import { useUiSetting$ } from '../../../lib/kibana'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; jest.mock('./use_set_alert_tags'); jest.mock('../../../lib/kibana'); -jest.mock( - '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', - () => ({ - useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true }), - }) -); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); + +const mockUseAlertsPrivileges = useAlertsPrivileges as jest.Mock; const defaultProps: UseBulkAlertTagsItemsProps = { refetch: () => {}, @@ -50,10 +48,7 @@ describe('useBulkAlertTagsItems', () => { beforeEach(() => { (useSetAlertTags as jest.Mock).mockReturnValue(jest.fn()); (useUiSetting$ as jest.Mock).mockReturnValue([['default-test-tag-1']]); - }); - - afterEach(() => { - jest.clearAllMocks(); + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: true }); }); it('should render alert tagging actions', () => { @@ -101,4 +96,15 @@ describe('useBulkAlertTagsItems', () => { }); expect(mockSetAlertTags).toHaveBeenCalled(); }); + + it('should return empty arrays when user does not have alerts privileges', () => { + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: false }); + + const { result } = renderHook(() => useBulkAlertTagsItems(defaultProps), { + wrapper: TestProviders, + }); + + expect(result.current.alertTagsItems.length).toEqual(0); + expect(result.current.alertTagsPanels.length).toEqual(0); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.tsx index 714d049524f51..458d619444ec4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.tsx @@ -26,7 +26,7 @@ export interface UseBulkAlertTagsPanel { } export const useBulkAlertTagsItems = ({ refetch }: UseBulkAlertTagsItemsProps) => { - const { hasIndexWrite } = useAlertsPrivileges(); + const { hasAlertsUpdate } = useAlertsPrivileges(); const setAlertTags = useSetAlertTags(); const handleOnAlertTagsSubmit = useCallback( async (tags, ids, onSuccess, setIsLoading) => { @@ -39,7 +39,7 @@ export const useBulkAlertTagsItems = ({ refetch }: UseBulkAlertTagsItemsProps) = const alertTagsItems = useMemo( () => - hasIndexWrite + hasAlertsUpdate ? [ { key: 'manage-alert-tags', @@ -51,7 +51,7 @@ export const useBulkAlertTagsItems = ({ refetch }: UseBulkAlertTagsItemsProps) = }, ] : [], - [hasIndexWrite] + [hasAlertsUpdate] ); const TitleContent = useMemo( @@ -89,7 +89,7 @@ export const useBulkAlertTagsItems = ({ refetch }: UseBulkAlertTagsItemsProps) = const alertTagsPanels: UseBulkAlertTagsPanel[] = useMemo( () => - hasIndexWrite + hasAlertsUpdate ? [ { id: 1, @@ -99,7 +99,7 @@ export const useBulkAlertTagsItems = ({ refetch }: UseBulkAlertTagsItemsProps) = }, ] : [], - [TitleContent, hasIndexWrite, renderContent] + [TitleContent, hasAlertsUpdate, renderContent] ); return useMemo(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/user_privileges_context.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/user_privileges_context.tsx index 38a823518af01..5b9cac2907e30 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/user_privileges_context.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/user_privileges_context.tsx @@ -19,6 +19,11 @@ import { extractRulesCapabilities, getRulesCapabilitiesInitialState, } from '../../utils/rules_capabilities'; +import type { AlertsUICapabilities } from '../../utils/alerts_capabilities'; +import { + extractAlertsCapabilities, + getAlertsCapabilitiesInitialState, +} from '../../utils/alerts_capabilities'; export interface UserPrivilegesState { listPrivileges: ReturnType; @@ -28,6 +33,7 @@ export interface UserPrivilegesState { timelinePrivileges: { crud: boolean; read: boolean }; notesPrivileges: { crud: boolean; read: boolean }; rulesPrivileges: RulesUICapabilities; + alertsPrivileges: AlertsUICapabilities; } export const initialUserPrivilegesState = (): UserPrivilegesState => ({ @@ -38,6 +44,7 @@ export const initialUserPrivilegesState = (): UserPrivilegesState => ({ timelinePrivileges: { crud: false, read: false }, notesPrivileges: { crud: false, read: false }, rulesPrivileges: getRulesCapabilitiesInitialState(), + alertsPrivileges: getAlertsCapabilitiesInitialState(), }); export const UserPrivilegesContext = createContext( initialUserPrivilegesState() @@ -59,10 +66,20 @@ export const UserPrivilegesProvider = ({ () => extractRulesCapabilities(kibanaCapabilities), [kibanaCapabilities] ); + + const alertsPrivileges = useMemo( + () => extractAlertsCapabilities(kibanaCapabilities), + [kibanaCapabilities] + ); + const shouldFetchListPrivileges = read || rulesPrivileges.rules.read; + const shouldFetchDetectionEnginePrivileges = + read || rulesPrivileges.rules.read || alertsPrivileges.alerts.read; const listPrivileges = useFetchListPrivileges(shouldFetchListPrivileges); - const detectionEnginePrivileges = useFetchDetectionEnginePrivileges(); + const detectionEnginePrivileges = useFetchDetectionEnginePrivileges( + shouldFetchDetectionEnginePrivileges + ); const endpointPrivileges = useEndpointPrivileges(); const siemPrivileges = useMemo( @@ -92,6 +109,7 @@ export const UserPrivilegesProvider = ({ timelinePrivileges, notesPrivileges, rulesPrivileges, + alertsPrivileges, }), [ listPrivileges, @@ -101,6 +119,7 @@ export const UserPrivilegesProvider = ({ timelinePrivileges, notesPrivileges, rulesPrivileges, + alertsPrivileges, ] ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_missing_privileges.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_missing_privileges.test.tsx index b92907dba3b52..694ad86b6e769 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_missing_privileges.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_missing_privileges.test.tsx @@ -9,7 +9,7 @@ import { renderHook } from '@testing-library/react'; import { useMissingPrivileges } from './use_missing_privileges'; import { useUserPrivileges } from '../components/user_privileges'; import { getUserPrivilegesMockDefaultValue } from '../components/user_privileges/__mocks__'; -import { RULES_FEATURE_ID } from '../../../common/constants'; +import { ALERTS_FEATURE_ID, RULES_FEATURE_ID } from '../../../common/constants'; jest.mock('../components/user_privileges'); jest.mock('../../detections/components/user_info'); @@ -117,6 +117,7 @@ describe('useMissingPrivileges', () => { rules: { edit: false, read: true }, exceptions: { edit: false, read: true }, }, + alertsPrivileges: { alerts: { edit: true, read: true, legacyUpdate: false } }, }) ); @@ -129,6 +130,26 @@ describe('useMissingPrivileges', () => { ); }); + it('reports missing alertsPrivileges if user cannot edit alerts', () => { + (useUserPrivileges as jest.Mock).mockReturnValue( + buildUseUserPrivilegesMockReturn({ + rulesPrivileges: { + rules: { edit: true, read: true }, + exceptions: { edit: true, read: true }, + }, + alertsPrivileges: { alerts: { edit: false, read: true, legacyUpdate: false } }, + }) + ); + + const hookResult = renderHook(() => useMissingPrivileges()); + + expect(hookResult.result.current).toEqual( + expect.objectContaining({ + featurePrivileges: expect.arrayContaining([[ALERTS_FEATURE_ID, ['all']]]), + }) + ); + }); + it('reports no privileges missing while listPrivileges result is null', () => { (useUserPrivileges as jest.Mock).mockReturnValue( buildUseUserPrivilegesMockReturn({ @@ -146,21 +167,35 @@ describe('useMissingPrivileges', () => { }); }); - it('reports missing "all" privilege for security if user does not have CRUD', () => { + it('reports missing "all" privilege for rules and alerts if user does not have edit permissions', () => { + (useUserPrivileges as jest.Mock).mockReturnValue( + buildUseUserPrivilegesMockReturn({ + rulesPrivileges: { + rules: { edit: false, read: true }, + exceptions: { edit: false, read: true }, + }, + alertsPrivileges: { alerts: { edit: false, read: true, legacyUpdate: false } }, + }) + ); + const hookResult = renderHook(() => useMissingPrivileges()); expect(hookResult.result.current.featurePrivileges).toEqual( - expect.arrayContaining([[RULES_FEATURE_ID, ['all']]]) + expect.arrayContaining([ + [RULES_FEATURE_ID, ['all']], + [ALERTS_FEATURE_ID, ['all']], + ]) ); }); - it('reports no missing rule privileges if user can edit rules', () => { + it('reports no missing feature privileges if user can edit rules and alerts', () => { (useUserPrivileges as jest.Mock).mockReturnValue( buildUseUserPrivilegesMockReturn({ rulesPrivileges: { rules: { edit: true, read: true }, exceptions: { edit: true, read: true }, }, + alertsPrivileges: { alerts: { edit: true, read: true, legacyUpdate: true } }, }) ); @@ -170,10 +205,23 @@ describe('useMissingPrivileges', () => { }); it('reports complex index privileges when all data is available', () => { + (useUserPrivileges as jest.Mock).mockReturnValue( + buildUseUserPrivilegesMockReturn({ + rulesPrivileges: { + rules: { edit: false, read: true }, + exceptions: { edit: false, read: true }, + }, + alertsPrivileges: { alerts: { edit: false, read: true, legacyUpdate: false } }, + }) + ); + const hookResult = renderHook(() => useMissingPrivileges()); expect(hookResult.result.current).toEqual({ - featurePrivileges: [[RULES_FEATURE_ID, ['all']]], + featurePrivileges: [ + [RULES_FEATURE_ID, ['all']], + [ALERTS_FEATURE_ID, ['all']], + ], indexPrivileges: [ ['.items-default', ['view_index_metadata', 'manage']], ['.lists-default', ['view_index_metadata', 'manage']], diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_missing_privileges.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_missing_privileges.ts index a320aff1f814a..9a570ffd9248f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_missing_privileges.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_missing_privileges.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { RULES_FEATURE_ID } from '../../../common/constants'; +import { ALERTS_FEATURE_ID, RULES_FEATURE_ID } from '../../../common/constants'; import type { Privilege } from '../../detections/containers/detection_engine/alerts/types'; import { useUserPrivileges } from '../components/user_privileges'; @@ -48,7 +48,8 @@ export interface MissingPrivileges { * Hook that returns index and feature privileges that are missing for the user. */ export const useMissingPrivileges = (): MissingPrivileges => { - const { detectionEnginePrivileges, listPrivileges, rulesPrivileges } = useUserPrivileges(); + const { detectionEnginePrivileges, listPrivileges, rulesPrivileges, alertsPrivileges } = + useUserPrivileges(); return useMemo(() => { const featurePrivileges: MissingFeaturePrivileges[] = []; @@ -69,6 +70,10 @@ export const useMissingPrivileges = (): MissingPrivileges => { featurePrivileges.push([RULES_FEATURE_ID, ['all']]); } + if (alertsPrivileges.alerts.edit === false) { + featurePrivileges.push([ALERTS_FEATURE_ID, ['all']]); + } + const missingItemsPrivileges = getMissingIndexPrivileges(listPrivileges.result.listItems.index); if (missingItemsPrivileges) { indexPrivileges.push(missingItemsPrivileges); @@ -90,5 +95,10 @@ export const useMissingPrivileges = (): MissingPrivileges => { featurePrivileges, indexPrivileges, }; - }, [listPrivileges.result, detectionEnginePrivileges.result, rulesPrivileges.rules.edit]); + }, [ + listPrivileges.result, + detectionEnginePrivileges.result, + rulesPrivileges.rules.edit, + alertsPrivileges.alerts.edit, + ]); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/utils/alerts_capabilities.ts b/x-pack/solutions/security/plugins/security_solution/public/common/utils/alerts_capabilities.ts new file mode 100644 index 0000000000000..5c96e5ab96440 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/utils/alerts_capabilities.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Capabilities } from '@kbn/core/types'; +import { + ALERTS_FEATURE_ID, + ALERTS_UI_EDIT, + ALERTS_UI_READ, + ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE, + RULES_FEATURE_ID_V1, + RULES_FEATURE_ID_V2, + SECURITY_FEATURE_ID_V1, + SECURITY_FEATURE_ID_V2, + SECURITY_FEATURE_ID_V3, + SECURITY_FEATURE_ID_V4, +} from '@kbn/security-solution-features/constants'; + +export interface AlertsUICapabilities { + alerts: { read: boolean; edit: boolean; legacyUpdate: boolean }; +} + +export const getAlertsCapabilitiesInitialState = () => ({ + alerts: { read: false, edit: false, legacyUpdate: false }, +}); + +export const extractAlertsCapabilities = (capabilities: Capabilities): AlertsUICapabilities => { + const alertsCapabilities = capabilities[ALERTS_FEATURE_ID]; + + // Alerts permissions + const readAlerts = alertsCapabilities?.[ALERTS_UI_READ] === true; + const editAlerts = alertsCapabilities?.[ALERTS_UI_EDIT] === true; + + // Legacy permissions to update alerts in order to preserve backwards compatibility + const deprecatedFeatures = [ + SECURITY_FEATURE_ID_V1, + SECURITY_FEATURE_ID_V2, + SECURITY_FEATURE_ID_V3, + SECURITY_FEATURE_ID_V4, + RULES_FEATURE_ID_V1, + RULES_FEATURE_ID_V2, + ]; + + const legacyUpdate = deprecatedFeatures.some( + (feature) => capabilities[feature]?.[ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE] + ); + + return { + alerts: { read: readAlerts, edit: editAlerts, legacyUpdate }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.test.tsx index 9a9cb57713359..ca38ff8a133d1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.test.tsx @@ -20,11 +20,13 @@ import { useRuleDetailsContext } from '../rule_details_context'; import { ExecutionLogTable } from './execution_log_table'; import { useKibana } from '../../../../../common/lib/kibana'; import { useKibana as mockUseKibana } from '../../../../../common/lib/kibana/__mocks__'; +import { useAlertsPrivileges } from '../../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; jest.mock('../../../../../sourcerer/containers'); jest.mock('../../../../rule_monitoring/components/execution_results_table/use_execution_results'); jest.mock('../rule_details_context'); jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../../../../common/hooks/use_experimental_features', () => { return { useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), @@ -61,10 +63,26 @@ mockUseRuleExecutionEvents.mockReturnValue({ isFetching: false, }); +const mockUseAlertsPrivileges = useAlertsPrivileges as jest.Mock; + +const defaultAlertsPrivileges = { + hasAlertsAll: true, + hasAlertsRead: true, + hasEncryptionKey: true, + hasIndexManage: true, + hasIndexMaintenance: true, + hasIndexRead: true, + hasIndexWrite: true, + hasIndexUpdateDelete: true, + isAuthenticated: true, + loading: false, +}; + describe('ExecutionLogTable', () => { beforeEach(() => { jest.clearAllMocks(); (useKibana as jest.Mock).mockReturnValue(mockedUseKibana); + mockUseAlertsPrivileges.mockReturnValue(defaultAlertsPrivileges); }); test('Shows total events returned', () => { @@ -93,4 +111,29 @@ describe('ExecutionLogTable', () => { expect(mockTelemetry.reportEvent).toHaveBeenCalled(); }); + + describe('actions column', () => { + const doRender = ({ hasAlertsRead }: { hasAlertsRead: boolean }) => { + const ruleDetailsContext = useRuleDetailsContextMock.create(); + (useRuleDetailsContext as jest.Mock).mockReturnValue(ruleDetailsContext); + mockUseAlertsPrivileges.mockReturnValue({ + ...defaultAlertsPrivileges, + hasAlertsRead, + }); + render(, { + wrapper: TestProviders, + }); + }; + it('renders filter action when user can read alerts', () => { + doRender({ hasAlertsRead: true }); + expect(screen.getAllByTestId('action-filter-by-execution-id').length).toBeGreaterThanOrEqual( + 1 + ); + }); + + it('does not render filter action when user cannot read alerts', () => { + doRender({ hasAlertsRead: false }); + expect(screen.queryByTestId('action-filter-by-execution-id')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.tsx index d03e1e3d9105a..73af782b07f27 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.tsx @@ -88,6 +88,7 @@ import { import { ExecutionLogSearchBar } from './execution_log_search_bar'; import { EventLogEventTypes } from '../../../../../common/lib/telemetry'; import { useDataView } from '../../../../../data_view_manager/hooks/use_data_view'; +import { useAlertsPrivileges } from '../../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; const EXECUTION_UUID_FIELD_NAME = 'kibana.alert.rule.execution.uuid'; @@ -131,6 +132,8 @@ const ExecutionLogTableComponent: React.FC = ({ telemetry, } = useKibana().services; + const { hasAlertsRead: canReadAlerts } = useAlertsPrivileges(); + const { [RuleDetailTabs.executionResults]: { state: { @@ -428,32 +431,36 @@ const ExecutionLogTableComponent: React.FC = ({ const actions = useMemo( () => [ - { - field: EXECUTION_UUID_FIELD_NAME, - name: i18n.COLUMN_ACTIONS, - width: '64px', - actions: [ - { - name: 'Edit', - isPrimary: true, - field: '', - description: i18n.COLUMN_ACTIONS_TOOLTIP, - icon: 'filter', - type: 'icon', - onClick: (executionEvent: RuleExecutionResult) => { - if (executionEvent?.execution_uuid) { - onFilterByExecutionIdCallback( - executionEvent.execution_uuid, - executionEvent.timestamp - ); - } + ...(canReadAlerts + ? [ + { + field: EXECUTION_UUID_FIELD_NAME, + name: i18n.COLUMN_ACTIONS, + width: '64px', + actions: [ + { + name: 'Edit', + isPrimary: true, + field: '', + description: i18n.COLUMN_ACTIONS_TOOLTIP, + icon: 'filter', + type: 'icon', + onClick: (executionEvent: RuleExecutionResult) => { + if (executionEvent?.execution_uuid) { + onFilterByExecutionIdCallback( + executionEvent.execution_uuid, + executionEvent.timestamp + ); + } + }, + 'data-test-subj': 'action-filter-by-execution-id', + }, + ], }, - 'data-test-subj': 'action-filter-by-execution-id', - }, - ], - }, + ] + : []), ], - [onFilterByExecutionIdCallback] + [onFilterByExecutionIdCallback, canReadAlerts] ); const getItemId = useCallback((item: RuleExecutionResult): string => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index b86b7922d2be4..ec2fdc0d7a121 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -156,6 +156,7 @@ import { RuleDetailTabs, useRuleDetailsTabs } from './use_rule_details_tabs'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useRuleUpdateCallout } from '../../../rule_management/hooks/use_rule_update_callout'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; const RULE_EXCEPTION_LIST_TYPES = [ ExceptionListTypeEnum.DETECTION, @@ -270,13 +271,13 @@ export const RuleDetailsPage = connector( isSignalIndexExists, isAuthenticated, hasEncryptionKey, - hasIndexRead, signalIndexName, hasIndexWrite, hasIndexMaintenance, }, ] = useUserData(); const canEditRules = useUserPrivileges().rulesPrivileges.rules.edit; + const { hasAlertsRead: canReadAlerts } = useAlertsPrivileges(); const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = useListsConfig(); @@ -313,7 +314,7 @@ export const RuleDetailsPage = connector( } }, [rule, startMlJobs]); - const pageTabs = useRuleDetailsTabs({ rule, ruleId, isExistingRule, hasIndexRead }); + const pageTabs = useRuleDetailsTabs({ rule, ruleId, isExistingRule, canReadAlerts }); const [isDeleteConfirmationVisible, showDeleteConfirmation, hideDeleteConfirmation] = useBoolState(); @@ -847,54 +848,56 @@ export const RuleDetailsPage = connector( - - <> - - - - - - - {updatedAtValue} - - - - - - - {ruleId != null && ( - + <> + - )} - - + + + + + + {updatedAtValue} + + + + + + + {ruleId != null && ( + + )} + + + )} { rule: mockRule, ruleId: mockRule.rule_id, isExistingRule: true, - hasIndexRead: false, + canReadAlerts: false, }); const tabsNames = Object.keys(tabs.result.current); @@ -105,7 +105,7 @@ describe('useRuleDetailsTabs', () => { rule: mockRule, ruleId: mockRule.rule_id, isExistingRule: true, - hasIndexRead: true, + canReadAlerts: true, }); const tabsNames = Object.keys(tabs.result.current); @@ -124,7 +124,7 @@ describe('useRuleDetailsTabs', () => { rule: mockRule, ruleId: mockRule.rule_id, isExistingRule: true, - hasIndexRead: true, + canReadAlerts: true, }); const tabsNames = Object.keys(tabs.result.current); @@ -151,7 +151,7 @@ describe('useRuleDetailsTabs', () => { }, ruleId: mockRule.rule_id, isExistingRule: true, - hasIndexRead: true, + canReadAlerts: true, }); const tabsNames = Object.keys(tabs.result.current); @@ -178,7 +178,7 @@ describe('useRuleDetailsTabs', () => { }, ruleId: mockRule.rule_id, isExistingRule: true, - hasIndexRead: true, + canReadAlerts: true, }); const tabsNames = Object.keys(tabs.result.current); @@ -204,7 +204,7 @@ describe('useRuleDetailsTabs', () => { }, ruleId: mockRule.rule_id, isExistingRule: true, - hasIndexRead: true, + canReadAlerts: true, }); const tabsNames = Object.keys(tabs.result.current); @@ -216,7 +216,7 @@ describe('useRuleDetailsTabs', () => { rule: mockRule, ruleId: mockRule.rule_id, isExistingRule: true, - hasIndexRead: true, + canReadAlerts: true, }); const tabsNames = Object.keys(tabs.result.current); @@ -234,7 +234,7 @@ describe('useRuleDetailsTabs', () => { rule: mockRule, ruleId: mockRule.rule_id, isExistingRule: true, - hasIndexRead: true, + canReadAlerts: true, }); const tabsNames = Object.keys(tabs.result.current); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/use_rule_details_tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/use_rule_details_tabs.tsx index c029b1e6dbd52..99422fd1cef55 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/use_rule_details_tabs.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/use_rule_details_tabs.tsx @@ -38,14 +38,14 @@ export interface UseRuleDetailsTabsProps { rule: Rule | null; ruleId: string; isExistingRule: boolean; - hasIndexRead: boolean | null; + canReadAlerts: boolean; } export const useRuleDetailsTabs = ({ rule, ruleId, isExistingRule, - hasIndexRead, + canReadAlerts, }: UseRuleDetailsTabsProps) => { const isEndpointExceptionsMovedFFEnabled = useIsExperimentalFeatureEnabled( 'endpointExceptionsMovedUnderManagement' @@ -102,7 +102,7 @@ export const useRuleDetailsTabs = ({ useEffect(() => { const hiddenTabs = []; - if (!hasIndexRead) { + if (!canReadAlerts) { hiddenTabs.push(RuleDetailTabs.alerts); } if (!ruleExecutionSettings.extendedLogging.isEnabled) { @@ -129,7 +129,7 @@ export const useRuleDetailsTabs = ({ }, [ canReadEndpointExceptions, canReadExceptions, - hasIndexRead, + canReadAlerts, isEndpointExceptionsMovedFFEnabled, rule, ruleDetailTabs, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx index 269f4db62f305..585f4ca13ecdf 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx @@ -21,6 +21,7 @@ import { useFetchIndex } from '../../../../common/containers/source'; import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import * as helpers from '../../utils/helpers'; import type { Rule } from '../../../rule_management/logic/types'; @@ -38,6 +39,7 @@ jest.mock('../../../../common/containers/source'); jest.mock('../../logic/use_create_update_exception'); jest.mock('../../logic/use_exception_flyout_data'); jest.mock('@kbn/lists-plugin/public'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../../rule_management/api/hooks/use_fetch_rule_by_id_query'); const mockGetExceptionBuilderComponentLazy = getExceptionBuilderComponentLazy as jest.Mock< @@ -51,6 +53,7 @@ const mockFetchIndexPatterns = useFetchIndexPatterns as jest.Mock< >; const mockUseSignalIndex = useSignalIndex as jest.Mock>>; const mockUseFetchIndex = useFetchIndex as jest.Mock; +const mockUseAlertsPrivileges = useAlertsPrivileges as jest.Mock; const alertDataMock: AlertData = { '@timestamp': '1234567890', @@ -74,6 +77,7 @@ describe('When the add exception modal is opened', () => { loading: false, signalIndexName: 'mock-siem-signals-index', })); + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: true }); }); afterEach(() => { @@ -1031,4 +1035,66 @@ describe('When the add exception modal is opened', () => { ).toEqual({ ...exceptionItemsInitialState, partialCodeSignatureWarningExists: true }); }); }); + + describe('when the user does not have permissions to write alerts', () => { + let wrapper: ReactWrapper; + beforeAll(async () => { + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: false }); + mockUseFetchIndex.mockImplementation(() => [ + false, + { + indexPatterns: stubIndexPattern, + }, + ]); + + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + afterAll(() => { + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: true }); + }); + + it('should not render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeFalsy(); + }); + + it('should not render the bulk close alerts checkbox', () => { + expect( + wrapper.find('[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').exists() + ).toBeFalsy(); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx index 92e8b9d67983e..2a870ac8865d7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx @@ -67,6 +67,7 @@ import { ExceptionFlyoutFooter } from '../flyout_components/footer'; import { ExceptionFlyoutHeader } from '../flyout_components/header'; import * as headerI18n from '../flyout_components/header/translations'; import { isSubmitDisabled, prepareNewItemsForSubmission, prepareToCloseAlerts } from './helpers'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; const SectionHeader = styled(EuiTitle)` ${() => css` @@ -113,6 +114,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ onCancel, onConfirm, }: AddExceptionFlyoutProps) { + const { hasAlertsUpdate } = useAlertsPrivileges(); const [showConfirmModal, setShowConfirmModal] = useState(false); const { isLoading, indexPatterns, getExtendedFields } = useFetchIndexPatterns(rules); const [isSubmitting, submitNewExceptionItems] = useAddNewExceptionItems(); @@ -619,7 +621,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ /> )} - {showAlertCloseOptions && ( + {hasAlertsUpdate && showAlertCloseOptions && ( <> >; const mockUseFindExceptionListReferences = useFindExceptionListReferences as jest.Mock; +const mockUseAlertsPrivileges = useAlertsPrivileges as jest.Mock; describe('When the edit exception modal is opened', () => { beforeEach(() => { @@ -77,6 +67,10 @@ describe('When the edit exception modal is opened', () => { signalIndexName: 'test-signal', }); mockUseAddOrUpdateException.mockImplementation(() => [false, jest.fn()]); + mockUseAlertsPrivileges.mockReturnValue({ + hasAlertsUpdate: true, + hasAlertsRead: true, + }); mockUseFetchIndex.mockImplementation(() => [ false, { @@ -164,14 +158,13 @@ describe('When the edit exception modal is opened', () => { describe('when the modal is loading', () => { it('renders the loading spinner', async () => { - // Mocks one of the hooks as loading mockFetchIndexPatterns.mockImplementation(() => ({ isLoading: true, indexPatterns: { fields: [], title: 'foo' }, getExtendedFields: () => Promise.resolve([]), })); - const wrapper = mount( + render( { /> ); + await waitFor(() => { - expect(wrapper.find('[data-test-subj="loadingEditExceptionFlyout"]').exists()).toBeTruthy(); + expect(screen.getByTestId('loadingEditExceptionFlyout')).toBeInTheDocument(); }); }); }); describe('exception list type of "endpoint"', () => { - mockUseFindExceptionListReferences.mockImplementation(() => [ - false, - false, - { - endpoint_list: { - ...getExceptionListSchemaMock(), - id: '123', - list_id: 'endpoint_list', - namespace_type: 'agnostic', - type: ExceptionListTypeEnum.ENDPOINT, - name: 'My exception list', - referenced_rules: [ - { - id: '345', - name: 'My rule', - rule_id: 'my_rule_id', - exception_lists: [ - { - id: 'endpoint_list', - list_id: 'endpoint_list', - namespace_type: 'single', - type: ExceptionListTypeEnum.ENDPOINT, - }, - ], - }, - ], + beforeEach(() => { + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + endpoint_list: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'endpoint_list', + namespace_type: 'agnostic', + type: ExceptionListTypeEnum.ENDPOINT, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: 'endpoint_list', + list_id: 'endpoint_list', + namespace_type: 'single', + type: ExceptionListTypeEnum.ENDPOINT, + }, + ], + }, + ], + }, }, - }, - jest.fn(), - ]); + jest.fn(), + ]); + }); describe('common functionality to test', () => { - let wrapper: ReactWrapper; beforeEach(async () => { - wrapper = mount( + render( { }); it('should render item name input', () => { - expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + expect(screen.getByTestId('exceptionFlyoutNameInput')).toBeInTheDocument(); }); it('should render OS info', () => { - expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeTruthy(); + expect(screen.getByTestId('exceptionItemSelectedOs')).toBeInTheDocument(); }); it('should render the exception builder', () => { - expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + expect(screen.getByTestId('edit-exception-builder')).toBeInTheDocument(); }); it('does NOT render section showing list or rule item assigned to', () => { - expect( - wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() - ).toBeFalsy(); - expect( - wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() - ).toBeFalsy(); + expect(screen.queryByTestId('exceptionItemLinkedToListSection')).not.toBeInTheDocument(); + expect(screen.queryByTestId('exceptionItemLinkedToRuleSection')).not.toBeInTheDocument(); }); it('should contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeTruthy(); + expect(screen.getByTestId('addExceptionEndpointText')).toBeInTheDocument(); }); it('should NOT display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).not.toBeTruthy(); + expect(screen.queryByTestId('eqlSequenceCallout')).not.toBeInTheDocument(); }); it('should show a warning callout if wildcard is used', async () => { @@ -308,10 +299,7 @@ describe('When the edit exception modal is opened', () => { }) ); - wrapper.update(); - expect( - wrapper.find('[data-test-subj="wildcardWithWrongOperatorCallout"]').exists() - ).toBeTruthy(); + expect(screen.getByTestId('wildcardWithWrongOperatorCallout')).toBeInTheDocument(); }); it('should show a warning callout if there is a partial code signature entry with only subject_name', async () => { @@ -334,10 +322,7 @@ describe('When the edit exception modal is opened', () => { }) ); - wrapper.update(); - expect( - wrapper.find('[data-test-subj="partialCodeSignatureCallout"]').exists() - ).toBeTruthy(); + expect(screen.getByTestId('partialCodeSignatureCallout')).toBeInTheDocument(); }); it('should show a warning callout if there is a partial code signature entry with only trusted field', async () => { @@ -360,15 +345,11 @@ describe('When the edit exception modal is opened', () => { }) ); - wrapper.update(); - expect( - wrapper.find('[data-test-subj="partialCodeSignatureCallout"]').exists() - ).toBeTruthy(); + expect(screen.getByTestId('partialCodeSignatureCallout')).toBeInTheDocument(); }); }); describe('when exception entry fields and index allow user to bulk close', () => { - let wrapper: ReactWrapper; beforeEach(async () => { const exceptionItemMock = { ...getExceptionListItemSchemaMock(), @@ -376,8 +357,9 @@ describe('When the edit exception modal is opened', () => { { field: 'file.hash.sha256', operator: 'included', type: 'match' }, ] as EntriesArray, }; - wrapper = mount( - + + render( + { onCancel={jest.fn()} onConfirm={jest.fn()} /> - + ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; await waitFor(() => @@ -413,17 +395,15 @@ describe('When the edit exception modal is opened', () => { }); it('should have the bulk close checkbox enabled', () => { - expect( - wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() - ).not.toBeDisabled(); + const checkbox = screen.getByTestId('bulkCloseAlertOnAddExceptionCheckbox'); + expect(checkbox).not.toBeDisabled(); }); }); describe('when entry has non ecs type', () => { - let wrapper: ReactWrapper; beforeEach(async () => { - wrapper = mount( - + render( + { onCancel={jest.fn()} onConfirm={jest.fn()} /> - + ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); }); it('should have the bulk close checkbox disabled', () => { - expect( - wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() - ).toBeDisabled(); + const checkbox = screen.getByTestId('bulkCloseAlertOnAddExceptionCheckbox'); + expect(checkbox).toBeDisabled(); }); }); }); describe('exception list type of "detection"', () => { - let wrapper: ReactWrapper; beforeEach(async () => { - wrapper = mount( + render( { }); it('should render item name input', () => { - expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + expect(screen.getByTestId('exceptionFlyoutNameInput')).toBeInTheDocument(); }); it('should not render OS info', () => { - expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeFalsy(); + expect(screen.queryByTestId('exceptionItemSelectedOs')).not.toBeInTheDocument(); }); it('should render the exception builder', () => { - expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + expect(screen.getByTestId('edit-exception-builder')).toBeInTheDocument(); }); it('does render section showing list item is assigned to', () => { - expect( - wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() - ).toBeTruthy(); + expect(screen.getByTestId('exceptionItemLinkedToListSection')).toBeInTheDocument(); }); it('does NOT render section showing rule item is assigned to', () => { - expect( - wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() - ).toBeFalsy(); + expect(screen.queryByTestId('exceptionItemLinkedToRuleSection')).not.toBeInTheDocument(); }); it('should NOT contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + expect(screen.queryByTestId('addExceptionEndpointText')).not.toBeInTheDocument(); }); it('should NOT display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeFalsy(); + expect(screen.queryByTestId('eqlSequenceCallout')).not.toBeInTheDocument(); }); }); describe('exception list type of "rule_default"', () => { - mockUseFindExceptionListReferences.mockImplementation(() => [ - false, - false, - { - my_list_id: { - ...getExceptionListSchemaMock(), - id: '123', - list_id: 'my_list_id', - namespace_type: 'single', - type: ExceptionListTypeEnum.RULE_DEFAULT, - name: 'My exception list', - referenced_rules: [ - { - id: '345', - name: 'My rule', - rule_id: 'my_rule_id', - exception_lists: [ - { - id: '1234', - list_id: 'my_list_id', - namespace_type: 'single', - type: ExceptionListTypeEnum.RULE_DEFAULT, - }, - ], - }, - ], + beforeEach(async () => { + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + my_list_id: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.RULE_DEFAULT, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: '1234', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.RULE_DEFAULT, + }, + ], + }, + ], + }, }, - }, - jest.fn(), - ]); + jest.fn(), + ]); - let wrapper: ReactWrapper; - beforeEach(async () => { - wrapper = mount( + render( { }); it('should render item name input', () => { - expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + expect(screen.getByTestId('exceptionFlyoutNameInput')).toBeInTheDocument(); }); it('should not render OS info', () => { - expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeFalsy(); + expect(screen.queryByTestId('exceptionItemSelectedOs')).not.toBeInTheDocument(); }); it('should render the exception builder', () => { - expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + expect(screen.getByTestId('edit-exception-builder')).toBeInTheDocument(); }); it('does NOT render section showing list item is assigned to', () => { - expect( - wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() - ).toBeFalsy(); + expect(screen.queryByTestId('exceptionItemLinkedToListSection')).not.toBeInTheDocument(); }); it('does render section showing rule item is assigned to', () => { - expect( - wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() - ).toBeTruthy(); + expect(screen.getByTestId('exceptionItemLinkedToRuleSection')).toBeInTheDocument(); }); it('should NOT contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + expect(screen.queryByTestId('addExceptionEndpointText')).not.toBeInTheDocument(); }); it('should NOT display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeFalsy(); + expect(screen.queryByTestId('eqlSequenceCallout')).not.toBeInTheDocument(); }); }); describe('when an exception assigned to a sequence eql rule type is passed', () => { - let wrapper: ReactWrapper; beforeEach(async () => { - wrapper = mount( + render( { /> ); - const callProps = (getExceptionBuilderComponentLazy as jest.Mock).mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); }); it('should have the bulk close checkbox disabled', () => { - expect( - wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() - ).toBeDisabled(); + const checkbox = screen.getByTestId('bulkCloseAlertOnAddExceptionCheckbox'); + expect(checkbox).toBeDisabled(); }); it('should display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeTruthy(); + expect(screen.getByTestId('eqlSequenceCallout')).toBeInTheDocument(); }); }); describe('error states', () => { - test('when there are exception builder errors has submit button disabled', async () => { - const wrapper = mount( + it('when there are exception builder errors has submit button disabled', async () => { + render( { const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); - expect( - wrapper.find('button[data-test-subj="editExceptionConfirmButton"]').getDOMNode() - ).toBeDisabled(); + expect(screen.getByTestId('editExceptionConfirmButton')).toBeDisabled(); }); - test('when there is a comment error has submit button disabled', async () => { - const { getByLabelText, queryByText, getByTestId } = render( + it('when there is a comment error has submit button disabled', async () => { + render( { ); - const commentInput = getByLabelText('Comment Input'); + const commentInput = screen.getByLabelText('Comment Input'); const commentErrorMessage = `The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH} characters.`; - expect(queryByText(commentErrorMessage)).toBeNull(); + expect(screen.queryByText(commentErrorMessage)).not.toBeInTheDocument(); // Put comment with the length above maximum allowed act(() => { @@ -796,8 +761,59 @@ describe('When the edit exception modal is opened', () => { }); fireEvent.blur(commentInput); }); - expect(queryByText(commentErrorMessage)).not.toBeNull(); - expect(getByTestId('editExceptionConfirmButton')).toBeDisabled(); + + expect(screen.getByText(commentErrorMessage)).toBeInTheDocument(); + expect(screen.getByTestId('editExceptionConfirmButton')).toBeDisabled(); + }); + }); + + describe('when user does not have alerts privileges', () => { + it('should NOT render bulk close alerts section when hasAlertsUpdate is false', async () => { + mockUseAlertsPrivileges.mockReturnValue({ + hasAlertsUpdate: false, + hasAlertsRead: false, + }); + + render( + + + + ); + + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + + expect(screen.queryByTestId('bulkCloseAlertOnAddExceptionCheckbox')).not.toBeInTheDocument(); }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx index 512bfbc615588..bff7f7e8e54b8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx @@ -69,6 +69,7 @@ import { ArtifactConfirmModal } from '../../../../management/components/artifact import { ExceptionFlyoutFooter } from '../flyout_components/footer'; import { ExceptionFlyoutHeader } from '../flyout_components/header'; import * as headerI18n from '../flyout_components/header/translations'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; interface EditExceptionFlyoutProps { list: ExceptionListSchema; @@ -111,6 +112,7 @@ const EditExceptionFlyoutComponent: React.FC = ({ const [isSubmitting, submitEditExceptionItems] = useEditExceptionItems(); const [isClosingAlerts, closeAlerts] = useCloseAlertsFromExceptions(); const { read: canReadExceptions } = useUserPrivileges().rulesPrivileges.exceptions; + const { hasAlertsUpdate } = useAlertsPrivileges(); const [ { @@ -480,7 +482,7 @@ const EditExceptionFlyoutComponent: React.FC = ({ /> )} - {showAlertCloseOptions && ( + {hasAlertsUpdate && showAlertCloseOptions && ( <> ) => { + const canReadRules = useUserPrivileges().rulesPrivileges.rules.read; + return useQuery( [...FIND_ONE_RULE_QUERY_KEY, id], async ({ signal }) => { @@ -38,7 +41,7 @@ export const useFetchRuleByIdQuery = (id: string, options?: UseQueryOptions { const [isPopoverOpen, , closePopover, togglePopover] = useBoolState(); + const ruleDetailsUrl = getRuleDetailsUrl(rule.id); const jobIds = getMachineLearningJobId(rule); if (!isMlRule(rule.type) || loadingJobs || !jobIds) { @@ -89,7 +89,7 @@ const MlRuleWarningPopoverComponent: React.FC {i18n.ML_RULE_JOBS_WARNING_BUTTON_LABEL} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/ml_rule_warning_popover.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/ml_rule_warning_popover.tsx index 1b0f77f554f62..f2a522566b059 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/ml_rule_warning_popover.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/ml_rule_warning_popover.tsx @@ -20,15 +20,14 @@ import type { SecurityJob } from '../../../../common/components/ml_popover/types import * as i18n from './translations'; import { useBoolState } from '../../../../common/hooks/use_bool_state'; -import { getRuleDetailsTabUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; import { SecurityPageName } from '../../../../../common/constants'; import { SecuritySolutionLinkButton } from '../../../../common/components/links'; import { isMlRule } from '../../../../../common/detection_engine/utils'; import { getCapitalizedStatusText } from '../../../common/components/rule_execution_status/utils'; import type { Rule } from '../../../rule_management/logic'; import { isJobStarted } from '../../../../../common/machine_learning/helpers'; -import { RuleDetailTabs } from '../../../rule_details_ui/pages/rule_details/use_rule_details_tabs'; import { getMachineLearningJobId } from '../../../common/helpers'; +import { getRuleDetailsUrl } from '../../../../common/components/link_to'; const POPOVER_WIDTH = '340px'; @@ -45,6 +44,7 @@ const MlRuleWarningPopoverComponent: React.FC { const [isPopoverOpen, , closePopover, togglePopover] = useBoolState(); const jobIds = getMachineLearningJobId(rule); + const ruleDetailsUrl = getRuleDetailsUrl(rule.id); if (!isMlRule(rule.type) || loadingJobs || !jobIds) { return null; @@ -89,7 +89,7 @@ const MlRuleWarningPopoverComponent: React.FC {i18n.ML_RULE_JOBS_WARNING_BUTTON_LABEL} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx index 84e7eef72b03f..d276c6c35ce18 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx @@ -22,7 +22,7 @@ jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges'); describe('MoreActionsRowControlColumn', () => { it('should render component with all options', async () => { - (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsUpdate: true }); (useKibana as jest.Mock).mockReturnValue({ services: { cases: { @@ -59,7 +59,7 @@ describe('MoreActionsRowControlColumn', () => { }); it('should not show cases actions if user is not authorized', async () => { - (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsUpdate: true }); (useKibana as jest.Mock).mockReturnValue({ services: { cases: { @@ -97,7 +97,7 @@ describe('MoreActionsRowControlColumn', () => { }); it('should not show tags actions if user is not authorized', async () => { - (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsUpdate: false }); (useKibana as jest.Mock).mockReturnValue({ services: { cases: { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/content.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/content.test.tsx index 4a16eaf861620..8e312c4163ee0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/content.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/content.test.tsx @@ -14,6 +14,12 @@ import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/dat import { GO_TO_RULES_BUTTON_TEST_ID } from './header/header_section'; import { FILTER_BY_ASSIGNEES_BUTTON } from '../../../common/components/filter_by_assignees_popover/test_ids'; import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { getUserPrivilegesMockDefaultValue } from '../../../common/components/user_privileges/__mocks__'; + +jest.mock('../../../common/components/user_privileges'); + +const mockUseUserPrivileges = useUserPrivileges as jest.Mock; const dataView: DataView = createStubDataView({ spec: {} }); const dataViewSpec: DataViewSpec = createStubDataView({ spec: {} }).toSpec(); @@ -22,6 +28,24 @@ const runtimeMappings: RunTimeMappings = createStubDataView({ }).getRuntimeMappings() as RunTimeMappings; describe('AlertsPageContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseUserPrivileges.mockReturnValue( + getUserPrivilegesMockDefaultValue({ + rulesPrivileges: { + rules: { + read: true, + edit: false, + }, + exceptions: { + read: false, + edit: false, + }, + }, + }) + ); + }); + it('should render correctly', async () => { render( @@ -41,4 +65,43 @@ describe('AlertsPageContent', () => { expect(screen.getByTestId('chartPanels')).toBeInTheDocument(); }); }); + + describe('when the user has no rules privileges', () => { + beforeEach(() => { + mockUseUserPrivileges.mockReturnValue( + getUserPrivilegesMockDefaultValue({ + rulesPrivileges: { + rules: { + read: false, + edit: false, + }, + exceptions: { + read: false, + edit: false, + }, + }, + }) + ); + }); + + it('renders the page content without the Go to Rules button', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId(SECURITY_SOLUTION_PAGE_WRAPPER_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId('header-page-title')).toHaveTextContent('Alerts'); + expect(screen.getByTestId(FILTER_BY_ASSIGNEES_BUTTON)).toBeInTheDocument(); + expect(screen.queryByTestId(GO_TO_RULES_BUTTON_TEST_ID)).not.toBeInTheDocument(); + expect(screen.getByTestId('chartPanels')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/header/header_section.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/header/header_section.test.tsx index db5de87a8930f..05cea4719cb6e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/header/header_section.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/header/header_section.test.tsx @@ -14,11 +14,15 @@ import { useSuggestUsers } from '../../../../common/components/user_profiles/use import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { useLicense } from '../../../../common/hooks/use_license'; import { useGetCurrentUserProfile } from '../../../../common/components/user_profiles/use_get_current_user_profile'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; jest.mock('../../../../common/hooks/use_license'); +jest.mock('../../../../common/components/user_privileges'); jest.mock('../../../../common/components/user_profiles/use_get_current_user_profile'); jest.mock('../../../../common/components/user_profiles/use_suggest_users'); +const mockUseUserPrivileges = useUserPrivileges as jest.Mock; + const currentUser: UserProfileWithAvatar = { uid: 'uid1', enabled: true, @@ -43,9 +47,16 @@ const user: UserProfileWithAvatar = { describe('HeaderSection', () => { beforeEach(() => { (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); + mockUseUserPrivileges.mockReturnValue({ + rulesPrivileges: { + rules: { + read: true, + }, + }, + }); }); - it('should render correctly', () => { + it('should render correctly with manage rules button', () => { const { getByTestId } = render( @@ -56,6 +67,24 @@ describe('HeaderSection', () => { expect(getByTestId(GO_TO_RULES_BUTTON_TEST_ID)).toBeInTheDocument(); }); + it('should not render manage rules button when showManageRulesButton is false', () => { + mockUseUserPrivileges.mockReturnValueOnce({ + rulesPrivileges: { + rules: { + read: false, + }, + }, + }); + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId(FILTER_BY_ASSIGNEES_BUTTON)).toBeInTheDocument(); + expect(queryByTestId(GO_TO_RULES_BUTTON_TEST_ID)).not.toBeInTheDocument(); + }); + it('should set assignee', async () => { const setAssignees = jest.fn(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/header/header_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/header/header_section.tsx index c046281b3747d..429cb9fe0e252 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/header/header_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/header/header_section.tsx @@ -14,6 +14,7 @@ import { FilterByAssigneesPopover } from '../../../../common/components/filter_b import type { AssigneesIdsSelection } from '../../../../common/components/assignees/types'; import { SecurityPageName } from '../../../../app/types'; import { SecuritySolutionLinkButton } from '../../../../common/components/links'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; const BUTTON_MANAGE_RULES = i18n.translate('xpack.securitySolution.alertsPage.buttonManageRules', { defaultMessage: 'Manage rules', @@ -36,6 +37,7 @@ export interface HeaderSectionProps { * UI section of the alerts page that renders the assignees button and a button to navigate to the rules page. */ export const HeaderSection = memo(({ assignees, setAssignees }: HeaderSectionProps) => { + const canReadRules = useUserPrivileges().rulesPrivileges.rules.read; const handleSelectedAssignees = useCallback( (newAssignees: AssigneesIdsSelection[]) => { if (!isEqual(newAssignees, assignees)) { @@ -53,15 +55,17 @@ export const HeaderSection = memo(({ assignees, setAssignees }: HeaderSectionPro onSelectionChange={handleSelectedAssignees} /> - - - {BUTTON_MANAGE_RULES} - - + {canReadRules ? ( + + + {BUTTON_MANAGE_RULES} + + + ) : null} ); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index 72abde6ab44f9..1e81c7174d6ab 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -17,7 +17,7 @@ import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '.. import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { TableId } from '@kbn/securitysolution-data-table'; import { TimelineId } from '../../../../../common/types/timeline'; -import { SECURITY_FEATURE_ID } from '../../../../../common/constants'; +import { ALERTS_FEATURE_ID, SECURITY_FEATURE_ID } from '../../../../../common/constants'; jest.mock('../../../../common/components/user_privileges'); @@ -67,7 +67,7 @@ const mockUseKibanaReturnValue = { services: { timelines: { ...mockTimelines }, application: { - capabilities: { [SECURITY_FEATURE_ID]: { crud_alerts: true, read_alerts: true } }, + capabilities: { [ALERTS_FEATURE_ID]: { edit: true, read: true }, [SECURITY_FEATURE_ID]: {} }, }, cases: { ...mockCasesContract(), @@ -105,7 +105,7 @@ jest.mock('../../../../common/lib/kibana', () => { }); jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges', () => ({ - useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true }), + useAlertsPrivileges: jest.fn().mockReturnValue({ hasAlertsUpdate: true }), })); const mockUseRunAlertWorkflowPanel = jest.fn().mockReturnValue({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.test.tsx new file mode 100644 index 0000000000000..f1636508fbf19 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react'; +import { useAddBulkToTimelineAction } from './use_add_bulk_to_timeline'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { TableId } from '@kbn/securitysolution-data-table'; +import { PageScope } from '../../../../data_view_manager/constants'; +import { TestProviders } from '../../../../common/mock'; + +// Mock all dependencies +jest.mock('../../../../common/components/user_privileges'); +jest.mock('../../../../data_view_manager/hooks/use_data_view', () => ({ + useDataView: jest.fn().mockReturnValue({ + dataView: { getRuntimeMappings: jest.fn().mockReturnValue({}) }, + }), +})); +jest.mock('../../../../data_view_manager/hooks/use_browser_fields', () => ({ + useBrowserFields: jest.fn().mockReturnValue({}), +})); +jest.mock('../../../../data_view_manager/hooks/use_selected_patterns', () => ({ + useSelectedPatterns: jest.fn().mockReturnValue([]), +})); +jest.mock('../../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(false), +})); +jest.mock('../../../../sourcerer/containers', () => ({ + useSourcererDataView: jest.fn().mockReturnValue({ + browserFields: {}, + dataViewId: 'test-data-view-id', + sourcererDataView: { runtimeFieldMap: {} }, + selectedPatterns: [], + }), +})); +jest.mock('../../../../timelines/containers', () => ({ + useTimelineEventsHandler: jest.fn().mockReturnValue([null, null, jest.fn()]), +})); +jest.mock('../../../../common/lib/kuery', () => ({ + combineQueries: jest.fn().mockReturnValue({ filterQuery: '' }), +})); + +jest.mock('./use_send_bulk_to_timeline', () => ({ + useSendBulkToTimeline: jest.fn().mockReturnValue({ + sendBulkEventsToTimelineHandler: jest.fn(), + }), +})); + +const mockUseUserPrivileges = useUserPrivileges as jest.Mock; + +const defaultProps = { + localFilters: [], + tableId: TableId.alertsOnAlertsPage, + from: '2020-07-07T08:20:18.966Z', + to: '2020-07-08T08:20:18.966Z', + scopeId: PageScope.alerts, +}; + +describe('useAddBulkToTimelineAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when the user has timeline read privileges', () => { + beforeEach(() => { + mockUseUserPrivileges.mockReturnValue({ + timelinePrivileges: { read: true }, + }); + }); + + it('should return timeline action', () => { + const { result } = renderHook(() => useAddBulkToTimelineAction(defaultProps), { + wrapper: TestProviders, + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + label: expect.any(String), + onClick: expect.any(Function), + key: 'add-bulk-to-timeline', + 'data-test-subj': 'investigate-bulk-in-timeline', + }); + }); + }); + + describe('when the user does not have timeline read privileges', () => { + beforeEach(() => { + mockUseUserPrivileges.mockReturnValue({ + timelinePrivileges: { read: false }, + }); + }); + + it('should return empty actions array', () => { + const { result } = renderHook(() => useAddBulkToTimelineAction(defaultProps), { + wrapper: TestProviders, + }); + + expect(result.current).toHaveLength(0); + expect(result.current).toEqual([]); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.tsx index ede9b99c9f6f0..98c0b1618be25 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import type { TimelineItem } from '@kbn/timelines-plugin/common'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import type { Filter } from '@kbn/es-query'; @@ -30,18 +29,14 @@ import { BULK_ADD_TO_TIMELINE_LIMIT } from '../../../../../common/constants'; import type { TimelineArgs } from '../../../../timelines/containers'; import { useTimelineEventsHandler } from '../../../../timelines/containers'; import type { State } from '../../../../common/store/types'; -import { useUpdateTimeline } from '../../../../timelines/components/open_timeline/use_update_timeline'; -import { useCreateTimeline } from '../../../../timelines/hooks/use_create_timeline'; import { INVESTIGATE_BULK_IN_TIMELINE } from '../translations'; -import { TimelineId } from '../../../../../common/types/timeline'; -import { TimelineTypeEnum } from '../../../../../common/api/timeline'; -import { sendBulkEventsToTimelineAction } from '../actions'; -import type { CreateTimelineProps } from '../types'; import type { Direction } from '../../../../../common/search_strategy'; import { useSourcererDataView } from '../../../../sourcerer/containers'; import { globalFiltersQuerySelector } from '../../../../common/store/inputs/selectors'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { useSendBulkToTimeline } from './use_send_bulk_to_timeline'; -const { setEventsLoading, setSelected } = dataTableActions; +const { setEventsLoading } = dataTableActions; export interface UseAddBulkToTimelineActionProps { /* filters being passed to the Alert/events table */ @@ -78,6 +73,10 @@ export const useAddBulkToTimelineAction = ({ const experimentalBrowserFields = useBrowserFields(scopeId); const experimentalSelectedPatterns = useSelectedPatterns(scopeId); + const { + timelinePrivileges: { read: canReadTimelines }, + } = useUserPrivileges(); + const { browserFields: oldBrowserFields, dataViewId: oldDataViewId, @@ -181,53 +180,7 @@ export const useAddBulkToTimelineAction = ({ } }, [selectAll, totalCount]); - const clearActiveTimeline = useCreateTimeline({ - timelineId: TimelineId.active, - timelineType: TimelineTypeEnum.default, - }); - - const updateTimeline = useUpdateTimeline(); - - const createTimeline = useCallback( - async ({ timeline, ruleNote, timeline: { filters: eventIdFilters } }: CreateTimelineProps) => { - await clearActiveTimeline(); - updateTimeline({ - duplicate: true, - from, - id: TimelineId.active, - notes: [], - timeline: { - ...timeline, - indexNames: timeline.indexNames ?? [], - show: true, - filters: eventIdFilters, - }, - to, - ruleNote, - }); - }, - [updateTimeline, clearActiveTimeline, from, to] - ); - - const sendBulkEventsToTimelineHandler = useCallback( - (items: TimelineItem[]) => { - sendBulkEventsToTimelineAction( - createTimeline, - items.map((item) => item.ecs), - 'KqlFilter' - ); - - dispatch( - setSelected({ - id: tableId, - isSelectAllChecked: false, - isSelected: false, - eventIds: selectedEventIds, - }) - ); - }, - [dispatch, createTimeline, selectedEventIds, tableId] - ); + const { sendBulkEventsToTimelineHandler } = useSendBulkToTimeline({ tableId, from, to }); const onActionClick = useCallback< NonNullable @@ -283,14 +236,19 @@ export const useAddBulkToTimelineAction = ({ }, [disableActionOnSelectAll]); const memoized = useMemo( - () => ({ - label: investigateInTimelineTitle, - key: 'add-bulk-to-timeline', - 'data-test-subj': 'investigate-bulk-in-timeline', - disableOnQuery: disableActionOnSelectAll, - onClick: onActionClick, - }), - [disableActionOnSelectAll, investigateInTimelineTitle, onActionClick] + () => + canReadTimelines + ? [ + { + label: investigateInTimelineTitle, + key: 'add-bulk-to-timeline', + 'data-test-subj': 'investigate-bulk-in-timeline', + disableOnQuery: disableActionOnSelectAll, + onClick: onActionClick, + }, + ] + : [], + [canReadTimelines, disableActionOnSelectAll, investigateInTimelineTitle, onActionClick] ); return memoized; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx index b3173bcff3770..5a315345fd034 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx @@ -66,7 +66,7 @@ const renderContextMenu = ( describe('useAlertAssigneesActions', () => { beforeEach(() => { (useAlertsPrivileges as jest.Mock).mockReturnValue({ - hasIndexWrite: true, + hasAlertsUpdate: true, }); (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); }); @@ -119,7 +119,7 @@ describe('useAlertAssigneesActions', () => { it("should not render alert assignees actions if user doesn't have write permissions", () => { (useAlertsPrivileges as jest.Mock).mockReturnValue({ - hasIndexWrite: false, + hasAlertsUpdate: false, }); const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), { wrapper: TestProviders, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx index 84d9f8b5ca5db..7ad631d67d986 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx @@ -28,7 +28,7 @@ export const useAlertAssigneesActions = ({ ecsRowData, refetch, }: UseAlertAssigneesActionsProps) => { - const { hasIndexWrite } = useAlertsPrivileges(); + const { hasAlertsUpdate } = useAlertsPrivileges(); const alertId = ecsRowData._id; const alertAssignments = useMemo( @@ -95,7 +95,7 @@ export const useAlertAssigneesActions = ({ ); return { - alertAssigneesItems: hasIndexWrite ? itemsToReturn : [], + alertAssigneesItems: hasAlertsUpdate ? itemsToReturn : [], alertAssigneesPanels: panelsToReturn, }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.test.tsx index 64461725b8622..1488c40a526f7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.test.tsx @@ -55,7 +55,7 @@ const renderContextMenu = ( describe('useAlertTagsActions', () => { beforeEach(() => { (useAlertsPrivileges as jest.Mock).mockReturnValue({ - hasIndexWrite: true, + hasAlertsUpdate: true, }); }); @@ -103,7 +103,7 @@ describe('useAlertTagsActions', () => { it("should not render alert tagging actions if user doesn't have write permissions", () => { (useAlertsPrivileges as jest.Mock).mockReturnValue({ - hasIndexWrite: false, + hasAlertsUpdate: false, }); const { result } = renderHook(() => useAlertTagsActions(defaultProps), { wrapper: TestProviders, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.tsx index 16509b00c1f72..ea64aba6a52af 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.tsx @@ -25,7 +25,7 @@ export const useAlertTagsActions = ({ ecsRowData, refetch, }: UseAlertTagsActionsProps) => { - const { hasIndexWrite } = useAlertsPrivileges(); + const { hasAlertsUpdate } = useAlertsPrivileges(); const alertId = ecsRowData._id; const alertTagData = useMemo(() => { return [ @@ -72,7 +72,7 @@ export const useAlertTagsActions = ({ ); return { - alertTagsItems: hasIndexWrite ? itemsToReturn : [], + alertTagsItems: hasAlertsUpdate ? itemsToReturn : [], alertTagsPanels: panelsToReturn, }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx index 14581681c1166..ffffd3798a7b9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx @@ -30,7 +30,7 @@ export const useAlertsActions = ({ refetch, }: Props) => { const dispatch = useDispatch(); - const { hasIndexWrite } = useAlertsPrivileges(); + const { hasAlertsUpdate } = useAlertsPrivileges(); const onStatusUpdate = useCallback(() => { closePopover(); @@ -74,6 +74,6 @@ export const useAlertsActions = ({ const { items: actionItems, panels } = useBulkActionItems(actionItemArgs); return useMemo(() => { - return hasIndexWrite ? { actionItems, panels } : { actionItems: [], panels: [] }; - }, [actionItems, hasIndexWrite, panels]); + return hasAlertsUpdate ? { actionItems, panels } : { actionItems: [], panels: [] }; + }, [actionItems, hasAlertsUpdate, panels]); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline.test.tsx new file mode 100644 index 0000000000000..e4b14504bc403 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline.test.tsx @@ -0,0 +1,259 @@ +/* + * 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 { renderHook, act } from '@testing-library/react'; +import React from 'react'; +import { useSendBulkToTimeline } from './use_send_bulk_to_timeline'; +import { TableId } from '@kbn/securitysolution-data-table'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; +import { useUpdateTimeline } from '../../../../timelines/components/open_timeline/use_update_timeline'; +import { useCreateTimeline } from '../../../../timelines/hooks/use_create_timeline'; +import { sendBulkEventsToTimelineAction } from '../actions'; +import type { TimelineItem } from '@kbn/timelines-plugin/common'; +import type { State } from '../../../../common/store/types'; +import { TimelineId } from '../../../../../common/types/timeline'; + +jest.mock('../../../../timelines/components/open_timeline/use_update_timeline'); +jest.mock('../../../../timelines/hooks/use_create_timeline'); +jest.mock('../actions', () => ({ + sendBulkEventsToTimelineAction: jest.fn(), +})); + +const mockUseUpdateTimeline = useUpdateTimeline as jest.Mock; +const mockUseCreateTimeline = useCreateTimeline as jest.Mock; +const mockSendBulkEventsToTimelineAction = sendBulkEventsToTimelineAction as jest.Mock; + +const defaultProps = { + tableId: TableId.alertsOnAlertsPage, + from: '2020-07-07T08:20:18.966Z', + to: '2020-07-08T08:20:18.966Z', +}; + +const mockTimelineItems: TimelineItem[] = [ + { + _id: 'test-id-1', + _index: 'test-index', + data: [], + ecs: { _id: 'test-id-1', _index: 'test-index' }, + }, + { + _id: 'test-id-2', + _index: 'test-index', + data: [], + ecs: { _id: 'test-id-2', _index: 'test-index' }, + }, +]; + +// Create a base table config to reuse +const createTableConfig = (tableId: TableId) => ({ + ...mockGlobalState.dataTable.tableById[TableId.test], + id: tableId, + selectedEventIds: {}, +}); + +// Create state with the required data tables initialized +const createTestState = (): State => ({ + ...mockGlobalState, + dataTable: { + tableById: { + ...mockGlobalState.dataTable.tableById, + [TableId.alertsOnAlertsPage]: createTableConfig(TableId.alertsOnAlertsPage), + [TableId.riskInputs]: createTableConfig(TableId.riskInputs), + [TableId.alertsOnAttacksPage]: createTableConfig(TableId.alertsOnAttacksPage), + }, + }, +}); + +describe('useSendBulkToTimeline', () => { + let store: ReturnType; + let mockUpdateTimeline: jest.Mock; + let mockClearActiveTimeline: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockUpdateTimeline = jest.fn(); + mockClearActiveTimeline = jest.fn().mockResolvedValue(undefined); + mockUseUpdateTimeline.mockReturnValue(mockUpdateTimeline); + mockUseCreateTimeline.mockReturnValue(mockClearActiveTimeline); + store = createMockStore(createTestState()); + }); + + const renderHookWithProviders = (props = defaultProps) => { + return renderHook(() => useSendBulkToTimeline(props), { + wrapper: ({ children }) => {children}, + }); + }; + + it('should return sendBulkEventsToTimelineHandler function', () => { + const { result } = renderHookWithProviders(); + + expect(result.current).toHaveProperty('sendBulkEventsToTimelineHandler'); + expect(typeof result.current.sendBulkEventsToTimelineHandler).toBe('function'); + }); + + describe('sendBulkEventsToTimelineHandler', () => { + it('should call sendBulkEventsToTimelineAction with correct parameters', () => { + const { result } = renderHookWithProviders(); + + act(() => { + result.current.sendBulkEventsToTimelineHandler(mockTimelineItems); + }); + + expect(mockSendBulkEventsToTimelineAction).toHaveBeenCalledTimes(1); + expect(mockSendBulkEventsToTimelineAction).toHaveBeenCalledWith( + expect.any(Function), + mockTimelineItems.map((item) => item.ecs), + 'KqlFilter' + ); + }); + + it('should extract ecs data from timeline items', () => { + const { result } = renderHookWithProviders(); + + act(() => { + result.current.sendBulkEventsToTimelineHandler(mockTimelineItems); + }); + + const ecsData = mockSendBulkEventsToTimelineAction.mock.calls[0][1]; + expect(ecsData).toEqual([ + { _id: 'test-id-1', _index: 'test-index' }, + { _id: 'test-id-2', _index: 'test-index' }, + ]); + }); + + it('should handle empty items array', () => { + const { result } = renderHookWithProviders(); + + act(() => { + result.current.sendBulkEventsToTimelineHandler([]); + }); + + expect(mockSendBulkEventsToTimelineAction).toHaveBeenCalledWith( + expect.any(Function), + [], + 'KqlFilter' + ); + }); + }); + + describe('createTimeline', () => { + const getCreateTimelineFn = (result: ReturnType['result']) => { + act(() => { + result.current.sendBulkEventsToTimelineHandler(mockTimelineItems); + }); + return mockSendBulkEventsToTimelineAction.mock.calls[0][0]; + }; + + const ruleNote = 'some note'; + + it('should clear active timeline before updating', async () => { + const { result } = renderHookWithProviders(); + const createTimeline = getCreateTimelineFn(result); + + await act(async () => { + await createTimeline({ + timeline: { indexNames: ['test-index'], filters: [] }, + ruleNote, + }); + }); + + expect(mockClearActiveTimeline).toHaveBeenCalledTimes(1); + }); + + it('should call updateTimeline with correct parameters', async () => { + const { result } = renderHookWithProviders(); + const createTimeline = getCreateTimelineFn(result); + + const mockFilters = [{ meta: { alias: 'test' } }]; + await act(async () => { + await createTimeline({ + timeline: { indexNames: ['test-index'], filters: mockFilters }, + ruleNote, + }); + }); + + expect(mockUpdateTimeline).toHaveBeenCalledTimes(1); + expect(mockUpdateTimeline).toHaveBeenCalledWith({ + duplicate: true, + from: defaultProps.from, + to: defaultProps.to, + id: TimelineId.active, + notes: [], + timeline: expect.objectContaining({ + indexNames: ['test-index'], + show: true, + filters: mockFilters, + }), + ruleNote, + }); + }); + + it('should use from and to props in timeline update', async () => { + const customProps = { + ...defaultProps, + from: '2023-01-01T00:00:00.000Z', + to: '2023-01-02T00:00:00.000Z', + }; + const { result } = renderHookWithProviders(customProps); + const createTimeline = getCreateTimelineFn(result); + + await act(async () => { + await createTimeline({ + timeline: { indexNames: [], filters: [] }, + ruleNote: '', + }); + }); + + expect(mockUpdateTimeline).toHaveBeenCalledWith( + expect.objectContaining({ + from: customProps.from, + to: customProps.to, + }) + ); + }); + + it('should default indexNames to empty array if not provided', async () => { + const { result } = renderHookWithProviders(); + const createTimeline = getCreateTimelineFn(result); + + await act(async () => { + await createTimeline({ + timeline: { indexNames: undefined, filters: [] }, + ruleNote: '', + }); + }); + + expect(mockUpdateTimeline).toHaveBeenCalledWith( + expect.objectContaining({ + timeline: expect.objectContaining({ + indexNames: [], + }), + }) + ); + }); + + it('should set show to true on the timeline', async () => { + const { result } = renderHookWithProviders(); + const createTimeline = getCreateTimelineFn(result); + + await act(async () => { + await createTimeline({ + timeline: { indexNames: [], filters: [] }, + ruleNote: '', + }); + }); + + expect(mockUpdateTimeline).toHaveBeenCalledWith( + expect.objectContaining({ + timeline: expect.objectContaining({ + show: true, + }), + }) + ); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline.tsx new file mode 100644 index 0000000000000..9eed69f1bb50d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline.tsx @@ -0,0 +1,97 @@ +/* + * 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 { TimelineItem } from '@kbn/timelines-plugin/common'; +import { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import type { TableId } from '@kbn/securitysolution-data-table'; +import { + dataTableActions, + dataTableSelectors, + tableDefaults, +} from '@kbn/securitysolution-data-table'; +import type { State } from '../../../../common/store/types'; +import { useUpdateTimeline } from '../../../../timelines/components/open_timeline/use_update_timeline'; +import { useCreateTimeline } from '../../../../timelines/hooks/use_create_timeline'; +import { TimelineId } from '../../../../../common/types/timeline'; +import { TimelineTypeEnum } from '../../../../../common/api/timeline'; +import { sendBulkEventsToTimelineAction } from '../actions'; +import type { CreateTimelineProps } from '../types'; + +const { setSelected } = dataTableActions; + +export interface UseSendBulkToTimelineProps { + /** ID of the data table (e.g. alerts table) used to read selected event IDs and clear selection after sending to timeline. */ + tableId: TableId; + /** Start of the time range applied to the timeline when bulk events are sent. */ + from: string; + /** End of the time range applied to the timeline when bulk events are sent. */ + to: string; +} + +/** + * Hook that provides a handler to send bulk events to the timeline. + * This can be used independently from the bulk actions table infrastructure. + */ +export const useSendBulkToTimeline = ({ tableId, from, to }: UseSendBulkToTimelineProps) => { + const dispatch = useDispatch(); + + const selectTableById = useMemo(() => dataTableSelectors.createTableSelector(tableId), [tableId]); + const { selectedEventIds } = useSelector( + (state: State) => selectTableById(state) ?? tableDefaults + ); + + const clearActiveTimeline = useCreateTimeline({ + timelineId: TimelineId.active, + timelineType: TimelineTypeEnum.default, + }); + + const updateTimeline = useUpdateTimeline(); + + const createTimeline = useCallback( + async ({ timeline, ruleNote, timeline: { filters: eventIdFilters } }: CreateTimelineProps) => { + await clearActiveTimeline(); + updateTimeline({ + duplicate: true, + from, + id: TimelineId.active, + notes: [], + timeline: { + ...timeline, + indexNames: timeline.indexNames ?? [], + show: true, + filters: eventIdFilters, + }, + to, + ruleNote, + }); + }, + [updateTimeline, clearActiveTimeline, from, to] + ); + + const sendBulkEventsToTimelineHandler = useCallback( + (items: TimelineItem[]) => { + sendBulkEventsToTimelineAction( + createTimeline, + items.map((item) => item.ecs), + 'KqlFilter' + ); + + dispatch( + setSelected({ + id: tableId, + isSelectAllChecked: false, + isSelected: false, + eventIds: selectedEventIds, + }) + ); + }, + [dispatch, createTimeline, selectedEventIds, tableId] + ); + + return { sendBulkEventsToTimelineHandler }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx index 20db922154790..daaa7624ba0d9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx @@ -233,8 +233,9 @@ describe('AttacksGroupTakeActionItems', () => { }); describe('investigate in timeline', () => { - it('should render the `Investigate in timeline` action item', async () => { + it('renders the `Investigate in timeline` action item when user has timeline read privileges', async () => { const { findByText } = renderAttack(mockAttack); + expect(await findByText('Investigate in timeline')).toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx index a6fa6a6091978..445c2bf7aa707 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx @@ -79,6 +79,7 @@ const userPrivilegesInitial: ReturnType = { timelinePrivileges: { crud: true, read: true }, notesPrivileges: { crud: true, read: true }, rulesPrivileges: { rules: { edit: true, read: true }, exceptions: { read: true, edit: false } }, + alertsPrivileges: { alerts: { edit: true, read: true, legacyUpdate: true } }, }; describe('useAlertsPrivileges', () => { @@ -103,6 +104,7 @@ describe('useAlertsPrivileges', () => { hasIndexUpdateDelete: null, hasAlertsRead: false, hasAlertsAll: false, + hasAlertsUpdate: false, isAuthenticated: null, loading: false, }) @@ -125,6 +127,7 @@ describe('useAlertsPrivileges', () => { hasIndexUpdateDelete: false, hasAlertsRead: true, hasAlertsAll: true, + hasAlertsUpdate: true, isAuthenticated: false, loading: false, }) @@ -151,6 +154,7 @@ describe('useAlertsPrivileges', () => { hasIndexUpdateDelete: true, hasAlertsRead: true, hasAlertsAll: true, + hasAlertsUpdate: true, isAuthenticated: true, loading: false, }) @@ -174,19 +178,17 @@ describe('useAlertsPrivileges', () => { hasIndexUpdateDelete: true, hasAlertsRead: true, hasAlertsAll: true, + hasAlertsUpdate: true, isAuthenticated: true, loading: false, }) ); }); - test('returns "hasAlertsAll" as false if user does not have SecurityRules "all" privilege', async () => { + test('returns "hasAlertsAll" as false if user does not have alerts "edit" privileges', async () => { const userPrivileges = produce(userPrivilegesInitial, (draft) => { draft.detectionEnginePrivileges.result = privilege; - draft.rulesPrivileges = { - rules: { edit: false, read: true }, - exceptions: { read: true, edit: false }, - }; + draft.alertsPrivileges = { alerts: { edit: false, read: true, legacyUpdate: false } }; }); useUserPrivilegesMock.mockReturnValue(userPrivileges); @@ -200,6 +202,7 @@ describe('useAlertsPrivileges', () => { hasIndexWrite: true, hasIndexUpdateDelete: true, hasAlertsAll: false, + hasAlertsUpdate: false, hasAlertsRead: true, isAuthenticated: true, loading: false, @@ -207,13 +210,10 @@ describe('useAlertsPrivileges', () => { ); }); - test('returns "hasAlertsRead" as false if user does not have the SecurityRules "read" privileges', async () => { + test('returns "hasAlertsRead" as false if user does not have alerts "read" privileges', async () => { const userPrivileges = produce(userPrivilegesInitial, (draft) => { draft.detectionEnginePrivileges.result = privilege; - draft.rulesPrivileges = { - rules: { edit: false, read: false }, - exceptions: { read: false, edit: false }, - }; + draft.alertsPrivileges = { alerts: { edit: false, read: false, legacyUpdate: false } }; }); useUserPrivilegesMock.mockReturnValue(userPrivileges); @@ -227,6 +227,32 @@ describe('useAlertsPrivileges', () => { hasIndexWrite: true, hasIndexUpdateDelete: true, hasAlertsAll: false, + hasAlertsUpdate: false, + hasAlertsRead: false, + isAuthenticated: true, + loading: false, + }) + ); + }); + + test('returns "hasAlertsUpdate" as true if the user has legacy permissions even when they don\'t have "hasAlertsAll"', async () => { + const userPrivileges = produce(userPrivilegesInitial, (draft) => { + draft.detectionEnginePrivileges.result = privilege; + draft.alertsPrivileges = { alerts: { edit: false, read: false, legacyUpdate: true } }; + }); + useUserPrivilegesMock.mockReturnValue(userPrivileges); + + const { result } = renderHook(() => useAlertsPrivileges()); + await waitFor(() => + expect(result.current).toEqual({ + hasEncryptionKey: true, + hasIndexManage: true, + hasIndexMaintenance: true, + hasIndexRead: true, + hasIndexWrite: true, + hasIndexUpdateDelete: true, + hasAlertsAll: false, + hasAlertsUpdate: true, hasAlertsRead: false, isAuthenticated: true, loading: false, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx index 0e54491df1603..f9b38236f3534 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx @@ -22,6 +22,7 @@ export interface AlertsPrivelegesState { hasIndexRead: boolean | null; hasAlertsRead: boolean; hasAlertsAll: boolean; + hasAlertsUpdate: boolean; } /** * Hook to get user privilege from @@ -30,9 +31,8 @@ export interface AlertsPrivelegesState { export const useAlertsPrivileges = (): UseAlertsPrivelegesReturn => { const { detectionEnginePrivileges: { error, result, loading }, - // Rules privileges implicitly contain alerts privileges. Until we separate them out into dedicated privileges, we are using rules privileges to determine alerts privileges. - rulesPrivileges: { - rules: { read: hasAlertsRead, edit: hasAlertsAll }, + alertsPrivileges: { + alerts: { edit: hasAlertsAll, read: hasAlertsRead, legacyUpdate: hasLegacyAlertsUpdate }, }, } = useUserPrivileges(); @@ -44,6 +44,7 @@ export const useAlertsPrivileges = (): UseAlertsPrivelegesReturn => { }, [result?.index]); const privileges = useMemo(() => { + const hasAlertsUpdate = hasAlertsAll || hasLegacyAlertsUpdate; if (error != null) { return { isAuthenticated: false, @@ -55,24 +56,30 @@ export const useAlertsPrivileges = (): UseAlertsPrivelegesReturn => { hasIndexMaintenance: false, hasAlertsRead, hasAlertsAll, + hasAlertsUpdate, }; } if (result != null && indexName) { + const hasIndexWrite = + result.index[indexName].create || + result.index[indexName].create_doc || + result.index[indexName].index || + result.index[indexName].write; + const hasIndexRead = result.index[indexName].read; return { isAuthenticated: result.is_authenticated, hasEncryptionKey: result.has_encryption_key, hasIndexManage: result.index[indexName].manage && result.cluster.manage, hasIndexMaintenance: result.index[indexName].maintenance, - hasIndexRead: result.index[indexName].read, - hasIndexWrite: - result.index[indexName].create || - result.index[indexName].create_doc || - result.index[indexName].index || - result.index[indexName].write, + hasIndexRead, + hasIndexWrite, hasIndexUpdateDelete: result.index[indexName].write, - hasAlertsRead, - hasAlertsAll, + // For now hasAlertsRead and hasAlertsAll will depend both on the RBAC setup and the explicit read/write access to the alerts index + // We do this to avoid doing this double wherever this hook is used. + hasAlertsRead: hasAlertsRead && hasIndexRead, + hasAlertsAll: hasAlertsAll && hasIndexWrite, + hasAlertsUpdate: hasAlertsUpdate && hasIndexWrite, }; } @@ -86,8 +93,9 @@ export const useAlertsPrivileges = (): UseAlertsPrivelegesReturn => { hasIndexMaintenance: null, hasAlertsRead: false, hasAlertsAll: false, + hasAlertsUpdate: false, }; - }, [error, result, indexName, hasAlertsRead, hasAlertsAll]); + }, [error, result, indexName, hasAlertsRead, hasAlertsAll, hasLegacyAlertsUpdate]); return { loading: loading ?? false, ...privileges }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.test.tsx index 431473a7d3080..d083af2543ab9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.test.tsx @@ -5,14 +5,18 @@ * 2.0. */ -import { render, renderHook } from '@testing-library/react'; +import { render, renderHook, waitFor } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../common/mock'; import { useGroupTakeActionsItems } from './use_group_take_action_items'; +import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; + jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges', () => ({ - useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true }), + useAlertsPrivileges: jest.fn(), })); +const mockUseAlertsPrivileges = useAlertsPrivileges as jest.Mock; + describe('useGroupTakeActionsItems', () => { const wrapperContainer: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( {children} @@ -29,6 +33,11 @@ describe('useGroupTakeActionsItems', () => { doc_count: 0, }, }; + + beforeEach(() => { + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: true }); + }); + it('returns all take actions items if showAlertStatusActions is true and currentStatus is undefined', async () => { const { result } = renderHook( () => @@ -171,4 +180,23 @@ describe('useGroupTakeActionsItems', () => { expect(buttons.length).toBe(3); }); + + describe('when the user does not have alert edit privileges', () => { + beforeEach(() => { + mockUseAlertsPrivileges.mockReturnValue({ hasAlertsUpdate: false }); + }); + + it('returns empty take actions items', async () => { + const { result } = renderHook( + () => + useGroupTakeActionsItems({ + showAlertStatusActions: true, + }), + { + wrapper: wrapperContainer, + } + ); + await waitFor(() => expect(result.current(getActionItemsParams)).toBeUndefined()); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.tsx index 50fc5c6aedd51..9c9c9268d0725 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.tsx @@ -38,6 +38,7 @@ import { AlertsEventTypes, METRIC_TYPE, track } from '../../../common/lib/teleme import type { StartServices } from '../../../types'; import { useAlertCloseInfoModal } from '../use_alert_close_info_modal'; import { useBulkAlertClosingReasonItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items'; +import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; const getTelemetryEvent = { groupedAlertsTakeAction: ({ @@ -78,6 +79,7 @@ export const useGroupTakeActionsItems = ({ const { services: { telemetry }, } = useKibana(); + const { hasAlertsUpdate } = useAlertsPrivileges(); const { promptAlertCloseConfirmation } = useAlertCloseInfoModal(); @@ -206,7 +208,7 @@ export const useGroupTakeActionsItems = ({ ({ query, tableId, groupNumber, selectedGroup }) => { const actionItems: EuiContextMenuPanelItemDescriptor[] = []; - if (!showAlertStatusActions) { + if (!hasAlertsUpdate || !showAlertStatusActions) { return; } @@ -335,6 +337,7 @@ export const useGroupTakeActionsItems = ({ getAlertClosingReasonPanels, onClickUpdate, showAlertStatusActions, + hasAlertsUpdate, ] ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.test.tsx index 0c866396445dc..6fc6c61141945 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.test.tsx @@ -13,7 +13,7 @@ import { FILTER_ACKNOWLEDGED, FILTER_OPEN } from '../../../../common/types'; jest.mock('../../../common/hooks/use_app_toasts'); jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges', () => ({ - useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true }), + useAlertsPrivileges: jest.fn().mockReturnValue({ hasAlertsUpdate: true }), })); jest.mock('../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn(), diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx index 1961e24cd7d47..4a07fd58780e5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx @@ -44,7 +44,7 @@ export const useBulkAlertActionItems = ({ to, refetch: refetchProp, }: UseBulkAlertActionItemsArgs) => { - const { hasIndexWrite } = useAlertsPrivileges(); + const { hasAlertsUpdate } = useAlertsPrivileges(); const { startTransaction } = useStartTransaction(); const { addSuccess, addError, addWarning } = useAppToasts(); @@ -215,7 +215,7 @@ export const useBulkAlertActionItems = ({ ); const items = useMemo(() => { - return hasIndexWrite + return hasAlertsUpdate ? ([FILTER_OPEN, FILTER_CLOSED, FILTER_ACKNOWLEDGED] .map((status) => { return getUpdateAlertStatusAction(status as AlertWorkflowStatus); @@ -223,7 +223,7 @@ export const useBulkAlertActionItems = ({ // Filter out undefined items .filter((item) => !!item) as BulkActionsConfig[]) : []; - }, [getUpdateAlertStatusAction, hasIndexWrite]); + }, [getUpdateAlertStatusAction, hasAlertsUpdate]); const panels = useMemo( () => [...alertClosingReasonPanels] as BulkActionsPanelConfig[], diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.test.tsx index 602cecba7f881..7d6427a488e02 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.test.tsx @@ -57,9 +57,11 @@ describe('useBulkActionsByTableType', () => { alertTagsPanels: [{ id: 'tagPanel' }], }); - (useAddBulkToTimelineActionModule.useAddBulkToTimelineAction as jest.Mock).mockReturnValue({ - key: 'add-bulk-to-timeline', - }); + (useAddBulkToTimelineActionModule.useAddBulkToTimelineAction as jest.Mock).mockReturnValue([ + { + key: 'add-bulk-to-timeline', + }, + ]); (useBulkAlertActionItemsModule.useBulkAlertActionItems as jest.Mock).mockReturnValue({ items: [{ id: 'action1' }, { id: 'action2' }], @@ -95,23 +97,6 @@ describe('useBulkActionsByTableType', () => { ]); }); - it('does not include timeline action if user does not have timeline read access', () => { - (useUserPrivileges as jest.Mock).mockReturnValue({ - timelinePrivileges: { read: false }, - }); - - const { result } = renderHook(() => - useBulkActionsByTableType(mockTableId, mockQuery, mockRefresh) - ); - - const [bulkActionsGroup] = result.current; - const timelineAction = bulkActionsGroup.items.find( - (item) => item.key === 'add-bulk-to-timeline' - ); - - expect(timelineAction).toBeUndefined(); - }); - it('passes correct parameters to dependent hooks', () => { renderHook(() => useBulkActionsByTableType(mockTableId, mockQuery, mockRefresh)); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx index 219c11f757197..13aba48d33516 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx @@ -20,7 +20,6 @@ import { useBulkRunAlertWorkflowPanel } from './use_bulk_run_alert_workflow_pane import { PageScope } from '../../../data_view_manager/constants'; import { useBulkAlertAssigneesItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items'; import { useBulkAlertTagsItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items'; -import { useUserPrivileges } from '../../../common/components/user_privileges'; import { useGlobalTime } from '../../../common/containers/use_global_time'; import { useAddBulkToTimelineAction } from '../../components/alerts_table/timeline_actions/use_add_bulk_to_timeline'; import { useBulkAlertActionItems } from './use_alert_actions'; @@ -122,15 +121,7 @@ export const useBulkActionsByTableType = ( }; }, [refresh]); - const { - timelinePrivileges: { read: hasTimelineReadPrivilege }, - } = useUserPrivileges(); - const addBulkToTimelineAction = useAddBulkToTimelineAction(timelineActionParams); - - const timelineActions = useMemo( - () => (hasTimelineReadPrivilege ? [addBulkToTimelineAction] : []), - [hasTimelineReadPrivilege, addBulkToTimelineAction] - ); + const timelineActions = useAddBulkToTimelineAction(timelineActionParams); const { items: alertActions, panels: alertActionsPanels } = useBulkAlertActionItems(alertActionParams); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/links.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/links.ts index 384f1bfa87d16..57434273e81c3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/links.ts @@ -7,9 +7,9 @@ import { i18n } from '@kbn/i18n'; import { - RULES_UI_DETECTIONS_PRIVILEGE, + ALERTS_UI_DETECTIONS_PRIVILEGE, RULES_UI_EXTERNAL_DETECTIONS_PRIVILEGE, - RULES_UI_READ_PRIVILEGE, + ALERTS_UI_READ_PRIVILEGE, } from '@kbn/security-solution-features/constants'; import { ALERT_DETECTIONS, @@ -25,7 +25,7 @@ import { IconAlerts } from '../common/icons/alerts'; import { IconAttacks } from '../common/icons/attacks'; export const alertsLink: LinkItem = { - capabilities: [[RULES_UI_READ_PRIVILEGE, RULES_UI_DETECTIONS_PRIVILEGE]], + capabilities: [[ALERTS_UI_READ_PRIVILEGE, ALERTS_UI_DETECTIONS_PRIVILEGE]], globalNavPosition: 3, globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.alerts', { @@ -48,7 +48,7 @@ const alertsSubLink: LinkItem = { }; const attacksSubLink: LinkItem = { - capabilities: [[RULES_UI_READ_PRIVILEGE, `${ATTACK_DISCOVERY_FEATURE_ID}.attack-discovery`]], + capabilities: [[ALERTS_UI_READ_PRIVILEGE, `${ATTACK_DISCOVERY_FEATURE_ID}.attack-discovery`]], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.attacks', { defaultMessage: 'Attacks', @@ -71,8 +71,8 @@ export const alertDetectionsLinks: LinkItem = { }), path: ALERT_DETECTIONS, capabilities: [ - [RULES_UI_READ_PRIVILEGE, RULES_UI_DETECTIONS_PRIVILEGE], - [RULES_UI_READ_PRIVILEGE, `${ATTACK_DISCOVERY_FEATURE_ID}.attack-discovery`], + [ALERTS_UI_READ_PRIVILEGE, ALERTS_UI_DETECTIONS_PRIVILEGE], + [ALERTS_UI_READ_PRIVILEGE, `${ATTACK_DISCOVERY_FEATURE_ID}.attack-discovery`], ], globalNavPosition: 3, globalSearchKeywords: [ @@ -93,7 +93,7 @@ export const alertDetectionsLinks: LinkItem = { }; export const alertSummaryLink: LinkItem = { - capabilities: [[RULES_UI_READ_PRIVILEGE, RULES_UI_EXTERNAL_DETECTIONS_PRIVILEGE]], + capabilities: [[ALERTS_UI_READ_PRIVILEGE, RULES_UI_EXTERNAL_DETECTIONS_PRIVILEGE]], globalNavPosition: 3, globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.alertSummary', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alerts.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alerts.test.tsx index 48f60c7eb5b5a..beb63c7accca7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alerts.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alerts.test.tsx @@ -18,16 +18,33 @@ import { NO_INDEX_TEST_ID } from '../../components/alerts/empty_pages/no_index_e import { NO_INTEGRATION_CALLOUT_TEST_ID } from '../../components/callouts/no_api_integration_key_callout'; import { NEED_ADMIN_CALLOUT_TEST_ID } from '../../../detection_engine/rule_management/components/callouts/need_admin_for_update_rules_callout'; import { useMissingPrivileges } from '../../../common/hooks/use_missing_privileges'; +import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; jest.mock('../../components/user_info'); jest.mock('../../../common/components/user_privileges'); jest.mock('../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../sourcerer/containers/use_signal_helpers'); jest.mock('../../../common/hooks/use_missing_privileges'); +jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../components/alerts/wrapper', () => ({ Wrapper: () =>

, })); +const mockUseAlertsPrivileges = useAlertsPrivileges as jest.Mock; + +const defaultAlertsPrivileges = { + hasAlertsAll: true, + hasAlertsRead: true, + hasEncryptionKey: true, + hasIndexManage: true, + hasIndexMaintenance: true, + hasIndexRead: true, + hasIndexWrite: true, + hasIndexUpdateDelete: true, + isAuthenticated: true, + loading: false, +}; + const doMockRulesPrivileges = ({ read = false }) => { (useUserPrivileges as jest.Mock).mockReturnValue({ rulesPrivileges: { @@ -43,6 +60,7 @@ describe('', () => { beforeEach(() => { jest.clearAllMocks(); doMockRulesPrivileges({}); + mockUseAlertsPrivileges.mockReturnValue(defaultAlertsPrivileges); }); describe('showing loading spinner', () => { @@ -137,7 +155,6 @@ describe('', () => { { loading: false, isAuthenticated: true, - hasIndexRead: true, hasEncryptionKey: false, }, ]); @@ -169,7 +186,6 @@ describe('', () => { { loading: false, isAuthenticated: true, - hasIndexRead: true, signalIndexMappingOutdated: true, hasIndexManage: false, }, @@ -202,7 +218,6 @@ describe('', () => { { loading: false, isAuthenticated: true, - hasIndexRead: true, }, ]); doMockRulesPrivileges({ read: true }); @@ -230,14 +245,17 @@ describe('', () => { }); describe('showing the actual content', () => { - it('should render NoPrivileges', () => { + it('should render NoPrivileges when user cannot read alerts', () => { (useUserData as jest.Mock).mockReturnValue([ { loading: false, isAuthenticated: true, - hasIndexRead: false, }, ]); + mockUseAlertsPrivileges.mockReturnValue({ + ...defaultAlertsPrivileges, + hasAlertsRead: false, + }); (useListsConfig as jest.Mock).mockReturnValue({ loading: false, needsConfiguration: false, @@ -266,10 +284,13 @@ describe('', () => { { loading: false, isAuthenticated: true, - hasIndexRead: true, }, ]); doMockRulesPrivileges({ read: true }); + mockUseAlertsPrivileges.mockReturnValue({ + ...defaultAlertsPrivileges, + hasAlertsRead: true, + }); (useListsConfig as jest.Mock).mockReturnValue({ loading: false, needsConfiguration: false, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alerts.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alerts.tsx index 1c356a09e3632..b1640b248e680 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alerts.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alerts.tsx @@ -21,7 +21,7 @@ import { NeedAdminForUpdateRulesCallOut } from '../../../detection_engine/rule_m import { MissingDetectionsPrivilegesCallOut } from '../../components/callouts/missing_detections_privileges_callout'; import { NoPrivileges } from '../../../common/components/no_privileges'; import { HeaderPage } from '../../../common/components/header_page'; -import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; export const ALERTS_PAGE_LOADING_TEST_ID = 'alerts-page-loading'; @@ -30,8 +30,8 @@ export const ALERTS_PAGE_LOADING_TEST_ID = 'alerts-page-loading'; * the actual content of the alerts page is rendered */ export const AlertsPage = memo(() => { - const [{ loading: userInfoLoading, isAuthenticated, hasIndexRead }] = useUserData(); - const canReadAlerts = useUserPrivileges().rulesPrivileges.rules.read; + const [{ loading: userInfoLoading, isAuthenticated }] = useUserData(); + const { hasAlertsRead: canReadAlerts } = useAlertsPrivileges(); const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = useListsConfig(); const { signalIndexNeedsInit } = useSignalHelpers(); @@ -49,8 +49,8 @@ export const AlertsPage = memo(() => { [needsListsConfiguration, signalIndexNeedsInit] ); const privilegesRequired: boolean = useMemo( - () => !signalIndexNeedsInit && (hasIndexRead === false || canReadAlerts === false), - [canReadAlerts, hasIndexRead, signalIndexNeedsInit] + () => !signalIndexNeedsInit && canReadAlerts === false, + [canReadAlerts, signalIndexNeedsInit] ); if (loading) { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/attacks/attacks.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/attacks/attacks.test.tsx index fa169f5b31021..d6aaa0a4b6803 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/attacks/attacks.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/attacks/attacks.test.tsx @@ -18,16 +18,33 @@ import { NO_INTEGRATION_CALLOUT_TEST_ID } from '../../components/callouts/no_api import { NEED_ADMIN_CALLOUT_TEST_ID } from '../../../detection_engine/rule_management/components/callouts/need_admin_for_update_rules_callout'; import { useMissingPrivileges } from '../../../common/hooks/use_missing_privileges'; import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; jest.mock('../../components/user_info'); jest.mock('../../../common/components/user_privileges'); jest.mock('../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../sourcerer/containers/use_signal_helpers'); jest.mock('../../../common/hooks/use_missing_privileges'); +jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../components/attacks/wrapper', () => ({ Wrapper: () =>
, })); +const mockUseAlertsPrivileges = useAlertsPrivileges as jest.Mock; + +const defaultAlertsPrivileges = { + hasAlertsAll: true, + hasAlertsRead: true, + hasEncryptionKey: true, + hasIndexManage: true, + hasIndexMaintenance: true, + hasIndexRead: true, + hasIndexWrite: true, + hasIndexUpdateDelete: true, + isAuthenticated: true, + loading: false, +}; + const doMockRulesPrivileges = ({ read = false }) => { (useUserPrivileges as jest.Mock).mockReturnValue({ rulesPrivileges: { @@ -43,6 +60,7 @@ describe('', () => { beforeEach(() => { jest.clearAllMocks(); doMockRulesPrivileges({}); + mockUseAlertsPrivileges.mockReturnValue(defaultAlertsPrivileges); }); describe('showing loading spinner', () => { @@ -138,7 +156,6 @@ describe('', () => { loading: false, isAuthenticated: true, canUserREAD: true, - hasIndexRead: true, hasEncryptionKey: false, }, ]); @@ -170,7 +187,6 @@ describe('', () => { loading: false, isAuthenticated: true, canUserREAD: true, - hasIndexRead: true, signalIndexMappingOutdated: true, hasIndexManage: false, }, @@ -203,7 +219,6 @@ describe('', () => { loading: false, isAuthenticated: true, canUserREAD: true, - hasIndexRead: true, }, ]); (useListsConfig as jest.Mock).mockReturnValue({ @@ -230,14 +245,17 @@ describe('', () => { }); describe('showing the actual content', () => { - it('should render NoPrivileges', () => { + it('should render NoPrivileges when the user has no access to alerts', () => { (useUserData as jest.Mock).mockReturnValue([ { loading: false, isAuthenticated: true, - hasIndexRead: false, }, ]); + mockUseAlertsPrivileges.mockReturnValue({ + ...defaultAlertsPrivileges, + hasAlertsRead: false, + }); (useListsConfig as jest.Mock).mockReturnValue({ loading: false, needsConfiguration: false, @@ -266,7 +284,6 @@ describe('', () => { { loading: false, isAuthenticated: true, - hasIndexRead: true, }, ]); doMockRulesPrivileges({ read: true }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/attacks/attacks.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/attacks/attacks.tsx index 29b341c3eaf21..d3c872b5eed15 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/attacks/attacks.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/attacks/attacks.tsx @@ -8,7 +8,6 @@ import { EuiFlexGroup, EuiLoadingSpinner } from '@elastic/eui'; import React, { memo, useMemo } from 'react'; import type { DocLinks } from '@kbn/doc-links'; -import { useUserPrivileges } from '../../../common/components/user_privileges'; import { Wrapper } from '../../components/attacks/wrapper'; import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; import { NoApiIntegrationKeyCallOut } from '../../components/callouts/no_api_integration_key_callout'; @@ -22,6 +21,7 @@ import { NeedAdminForUpdateRulesCallOut } from '../../../detection_engine/rule_m import { MissingAttacksPrivilegesCallOut } from '../../components/callouts/missing_attacks_privileges_callout'; import { NoPrivileges } from '../../../common/components/no_privileges'; import { HeaderPage } from '../../../common/components/header_page'; +import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; export const ATTACKS_PAGE_LOADING_TEST_ID = 'attacks-page-loading'; @@ -30,8 +30,8 @@ export const ATTACKS_PAGE_LOADING_TEST_ID = 'attacks-page-loading'; * the actual content of the attacks page is rendered */ export const AttacksPage = memo(() => { - const [{ loading: userInfoLoading, isAuthenticated, hasIndexRead }] = useUserData(); - const canReadAlerts = useUserPrivileges().rulesPrivileges.rules.read; + const [{ loading: userInfoLoading, isAuthenticated }] = useUserData(); + const { hasAlertsRead: canReadAlerts } = useAlertsPrivileges(); const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = useListsConfig(); const { signalIndexNeedsInit } = useSignalHelpers(); @@ -49,8 +49,8 @@ export const AttacksPage = memo(() => { [needsListsConfiguration, signalIndexNeedsInit] ); const privilegesRequired: boolean = useMemo( - () => !signalIndexNeedsInit && (hasIndexRead === false || canReadAlerts === false), - [canReadAlerts, hasIndexRead, signalIndexNeedsInit] + () => !signalIndexNeedsInit && canReadAlerts === false, + [canReadAlerts, signalIndexNeedsInit] ); if (loading) { diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions.ts index 53f048576927d..a2e15761be6e2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions.ts @@ -5,34 +5,19 @@ * 2.0. */ -import { TableId } from '@kbn/securitysolution-data-table'; import { useMemo } from 'react'; import { get, noop } from 'lodash/fp'; import { AttachmentType } from '@kbn/cases-plugin/common'; import type { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public'; import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; - -import { PageScope } from '../../../../data_view_manager/constants'; -import { useGlobalTime } from '../../../../common/containers/use_global_time'; -import { useAddBulkToTimelineAction } from '../../../../detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline'; import { useKibana } from '../../../../common/lib/kibana/kibana_react'; import type { InputAlert } from '../../../hooks/use_risk_contributing_alerts'; -import { EntityEventTypes } from '../../../../common/lib/telemetry'; /** * The returned actions only support alerts risk inputs. */ export const useRiskInputActions = (inputs: InputAlert[], closePopover: () => void) => { - const { from, to } = useGlobalTime(); - const timelineAction = useAddBulkToTimelineAction({ - localFilters: [], - from, - to, - scopeId: PageScope.alerts, - tableId: TableId.riskInputs, - }); - - const { cases: casesService, telemetry } = useKibana().services; + const { cases: casesService } = useKibana().services; const createCaseFlyout = casesService?.hooks.useCasesAddToNewCaseFlyout({ onSuccess: noop }); const selectCaseModal = casesService?.hooks.useCasesAddToExistingCaseModal(); @@ -60,40 +45,7 @@ export const useRiskInputActions = (inputs: InputAlert[], closePopover: () => vo closePopover(); createCaseFlyout.open({ attachments: caseAttachments }); }, - - addToNewTimeline: () => { - telemetry.reportEvent(EntityEventTypes.AddRiskInputToTimelineClicked, { - quantity: inputs.length, - }); - - closePopover(); - timelineAction.onClick( - inputs.map(({ input }: InputAlert) => { - return { - _id: input.id, - _index: input.index, - data: [], - ecs: { - _id: input.id, - _index: input.index, - }, - }; - }), - false, - noop, - noop, - noop - ); - }, }), - [ - inputs, - caseAttachments, - closePopover, - createCaseFlyout, - selectCaseModal, - telemetry, - timelineAction, - ] + [caseAttachments, closePopover, createCaseFlyout, selectCaseModal] ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.test.tsx index 4c2bd22fd525d..59d88072efe1d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.test.tsx @@ -13,13 +13,12 @@ import React from 'react'; import { TestProviders } from '../../../../common/mock'; import { alertInputDataMock } from '../mocks'; import { useRiskInputActionsPanels } from './use_risk_input_actions_panels'; +import { useSendBulkToTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { EntityEventTypes } from '../../../../common/lib/telemetry'; const casesServiceMock = casesPluginMock.createStartContract(); -const mockCanUseCases = jest.fn().mockReturnValue({ - create: true, - read: true, -}); +const mockCanUseCases = jest.fn(); const mockedCasesServices = { ...casesServiceMock, @@ -29,8 +28,9 @@ const mockedCasesServices = { }, }; -jest.mock('@kbn/kibana-react-plugin/public', () => { - const original = jest.requireActual('@kbn/kibana-react-plugin/public'); +const mockReportEvent = jest.fn(); +jest.mock('../../../../common/lib/kibana/kibana_react', () => { + const original = jest.requireActual('../../../../common/lib/kibana/kibana_react'); return { ...original, useKibana: () => ({ @@ -38,17 +38,21 @@ jest.mock('@kbn/kibana-react-plugin/public', () => { services: { ...original.useKibana().services, cases: mockedCasesServices, + telemetry: { + reportEvent: mockReportEvent, + }, }, }), }; }); +jest.mock( + '../../../../detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline' +); jest.mock('../../../../common/components/user_privileges'); -(useUserPrivileges as jest.Mock).mockReturnValue({ - timelinePrivileges: { - read: false, - }, -}); + +const mockUseSendBulkToTimeline = useSendBulkToTimeline as jest.Mock; +const mockUseUserPrivileges = useUserPrivileges as jest.Mock; const TestMenu = ({ panels }: { panels: EuiContextMenuPanelDescriptor[] }) => ( @@ -61,12 +65,26 @@ const customRender = (alerts = [alertInputDataMock]) => { return render( - + ); }; describe('useRiskInputActionsPanels', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCanUseCases.mockReturnValue({ + create: true, + read: true, + }); + mockUseSendBulkToTimeline.mockReturnValue({ + sendBulkEventsToTimelineHandler: jest.fn(), + }); + mockUseUserPrivileges.mockReturnValue({ + timelinePrivileges: { read: false }, + }); + }); + it('displays the rule name when only one alert is selected', () => { const { getByTestId } = customRender(); @@ -99,20 +117,63 @@ describe('useRiskInputActionsPanels', () => { }); it('displays the timeline action when user has sufficient privileges', () => { - (useUserPrivileges as jest.Mock).mockReturnValue({ + mockUseUserPrivileges.mockReturnValue({ timelinePrivileges: { read: true }, }); + const { container } = customRender(); expect(container).toHaveTextContent('Add to new timeline'); }); - it('does NOT display the timeline action when user has NO insufficient privileges', () => { - (useUserPrivileges as jest.Mock).mockReturnValue({ + it('does NOT display the timeline action when user has insufficient privileges', () => { + mockUseUserPrivileges.mockReturnValue({ timelinePrivileges: { read: false }, }); + const { container } = customRender(); expect(container).not.toHaveTextContent('Add to new timeline'); }); + + it('calls sendBulkEventsToTimelineHandler when timeline action is clicked', () => { + const mockSendBulkEvents = jest.fn(); + mockUseSendBulkToTimeline.mockReturnValue({ + sendBulkEventsToTimelineHandler: mockSendBulkEvents, + }); + mockUseUserPrivileges.mockReturnValue({ + timelinePrivileges: { read: true }, + }); + + const closePopover = jest.fn(); + const { result } = renderHook( + () => useRiskInputActionsPanels([alertInputDataMock], closePopover), + { + wrapper: TestProviders, + } + ); + + const timelineAction = result.current[0].items?.find( + (item: Partial<{ name: React.JSX.Element }>) => + item.name?.props?.defaultMessage === 'Add to new timeline' + ); + + timelineAction?.onClick?.(); + + expect(mockSendBulkEvents).toHaveBeenCalledWith([ + { + _id: alertInputDataMock.input.id, + _index: alertInputDataMock.input.index, + data: [], + ecs: { + _id: alertInputDataMock.input.id, + _index: alertInputDataMock.input.index, + }, + }, + ]); + expect(closePopover).toHaveBeenCalled(); + expect(mockReportEvent).toHaveBeenCalledWith(EntityEventTypes.AddRiskInputToTimelineClicked, { + quantity: 1, + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.tsx index 1dd23d37206ae..bddf26b8bf52f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.tsx @@ -8,39 +8,72 @@ import { EuiTextTruncate } from '@elastic/eui'; import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; import { SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; +import { TableId } from '@kbn/securitysolution-data-table'; import { i18n } from '@kbn/i18n'; import { get } from 'lodash/fp'; import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; -import type { CasesPublicStart } from '@kbn/cases-plugin/public'; import { useRiskInputActions } from './use_risk_input_actions'; import type { InputAlert } from '../../../hooks/use_risk_contributing_alerts'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { useSendBulkToTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { EntityEventTypes } from '../../../../common/lib/telemetry'; +import { useKibana } from '../../../../common/lib/kibana/kibana_react'; export const useRiskInputActionsPanels = (inputs: InputAlert[], closePopover: () => void) => { - const { cases: casesService } = useKibana<{ cases?: CasesPublicStart }>().services; - const { addToExistingCase, addToNewCaseClick, addToNewTimeline } = useRiskInputActions( - inputs, - closePopover - ); - const userCasesPermissions = casesService?.helpers.canUseCases([SECURITY_SOLUTION_OWNER]); - const hasCasesPermissions = userCasesPermissions?.create && userCasesPermissions?.read; + const { cases: casesService, telemetry } = useKibana().services; + const { addToExistingCase, addToNewCaseClick } = useRiskInputActions(inputs, closePopover); + const { from, to } = useGlobalTime(); const { - timelinePrivileges: { read: canAddToTimeline }, + timelinePrivileges: { read: canReadTimelines }, } = useUserPrivileges(); + const userCasesPermissions = casesService?.helpers.canUseCases([SECURITY_SOLUTION_OWNER]); + const hasCasesPermissions = userCasesPermissions?.create && userCasesPermissions?.read; - return useMemo(() => { - const timelinePanel = { - name: ( - - ), + const { sendBulkEventsToTimelineHandler } = useSendBulkToTimeline({ + to, + from, + tableId: TableId.riskInputs, + }); + const timelineActions = useMemo(() => { + if (!canReadTimelines) { + return []; + } + + return [ + { + name: ( + + ), + + onClick: () => { + telemetry.reportEvent(EntityEventTypes.AddRiskInputToTimelineClicked, { + quantity: inputs.length, + }); + + closePopover(); + const items = inputs.map(({ input }: InputAlert) => { + return { + _id: input.id, + _index: input.index, + data: [], + ecs: { + _id: input.id, + _index: input.index, + }, + }; + }); + sendBulkEventsToTimelineHandler(items); + }, + }, + ]; + }, [canReadTimelines, inputs, sendBulkEventsToTimelineHandler, closePopover, telemetry]); - onClick: addToNewTimeline, - }; + return useMemo(() => { const ruleName = get(['alert', ALERT_RULE_NAME], inputs[0]) ?? ''; const title = i18n.translate( 'xpack.securitySolution.flyout.entityDetails.riskInputs.actions.title', @@ -73,7 +106,7 @@ export const useRiskInputActionsPanels = (inputs: InputAlert[], closePopover: () ), id: 0, items: [ - ...(canAddToTimeline ? [timelinePanel] : []), + ...timelineActions, ...(hasCasesPermissions ? [ { @@ -102,12 +135,5 @@ export const useRiskInputActionsPanels = (inputs: InputAlert[], closePopover: () ], }, ]; - }, [ - addToExistingCase, - addToNewCaseClick, - addToNewTimeline, - inputs, - hasCasesPermissions, - canAddToTimeline, - ]); + }, [addToExistingCase, addToNewCaseClick, inputs, hasCasesPermissions, timelineActions]); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/hooks/use_risk_contributing_alerts.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/hooks/use_risk_contributing_alerts.ts index 74ca1d2751863..a505c0eaa4037 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/hooks/use_risk_contributing_alerts.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/hooks/use_risk_contributing_alerts.ts @@ -11,6 +11,7 @@ import type { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; import type { EntityType } from '../../../common/entity_analytics/types'; import type { RiskScoreInput } from '../../../common/api/entity_analytics/common'; import { useQueryAlerts } from '../../detections/containers/detection_engine/alerts/use_query'; +import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { ALERTS_QUERY_NAMES } from '../../detections/containers/detection_engine/alerts/constants'; import type { EntityRiskScore } from '../../../common/search_strategy/security_solution/risk_score/all'; @@ -50,9 +51,11 @@ export const useRiskContributingAlerts = ({ riskScore, entityType, }: UseRiskContributingAlerts): UseRiskContributingAlertsResult => { + const { hasAlertsRead } = useAlertsPrivileges(); const { loading, data, setQuery } = useQueryAlerts({ query: {}, queryName: ALERTS_QUERY_NAMES.BY_ID, + skip: !hasAlertsRead, }); const inputs = getInputs(riskScore, entityType); diff --git a/x-pack/solutions/security/plugins/security_solution/public/exceptions/components/link_to_rule_details/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/exceptions/components/link_to_rule_details/index.tsx index d2526f6fdef03..415cb24170813 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/exceptions/components/link_to_rule_details/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/exceptions/components/link_to_rule_details/index.tsx @@ -9,9 +9,8 @@ import React from 'react'; import type { FC } from 'react'; import { SecuritySolutionLinkAnchor } from '../../../common/components/links'; -import { RuleDetailTabs } from '../../../detection_engine/rule_details_ui/pages/rule_details/use_rule_details_tabs'; import { SecurityPageName } from '../../../../common/constants'; -import { getRuleDetailsTabUrl } from '../../../common/components/link_to/redirect_to_detection_engine'; +import { getRuleDetailsUrl } from '../../../common/components/link_to'; interface LinkToRuleDetailsProps { referenceName: string; @@ -28,11 +27,12 @@ const LinkToRuleDetailsComponent: FC = ({ external, dataTestSubj, }) => { + const ruleDetailsUrl = getRuleDetailsUrl(referenceId); return ( {referenceName} diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details.test.tsx index 736469c125128..233b34a66faf5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details.test.tsx @@ -32,7 +32,11 @@ import { mockContextValue } from '../../shared/mocks/mock_context'; import { EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID } from '../../../../flyout_v2/shared/components/test_ids'; import { useSecurityDefaultPatterns } from '../../../../data_view_manager/hooks/use_security_default_patterns'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; +const useAlertsPrivilegesMock = useAlertsPrivileges as jest.Mock; + +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; @@ -73,6 +77,9 @@ describe('CorrelationsDetails', () => { (useSecurityDefaultPatterns as jest.Mock).mockReturnValue({ indexPatterns: ['index'], }); + useAlertsPrivilegesMock.mockReturnValue({ + hasAlertsRead: true, + }); }); it('renders all sections', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.test.tsx index 94aca3eee9422..7de4521febdd8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.test.tsx @@ -14,6 +14,7 @@ import { type CorrelationsCustomTableColumn, } from './correlations_details_alerts_table'; import { usePaginatedAlerts } from '../hooks/use_paginated_alerts'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { mockContextValue } from '../../shared/mocks/mock_context'; import { DocumentDetailsPreviewPanelKey } from '../../shared/constants/panel_keys'; @@ -22,9 +23,25 @@ import { RulePreviewPanelKey, RULE_PREVIEW_BANNER } from '../../../rule_details/ import { TableId } from '@kbn/securitysolution-data-table'; jest.mock('../hooks/use_paginated_alerts'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); + +const useAlertsPrivilegesMock = useAlertsPrivileges as jest.Mock; jest.mock('@kbn/expandable-flyout'); +jest.mock('../../../../common/components/user_privileges', () => ({ + useUserPrivileges: () => ({ + timelinePrivileges: { + read: true, + }, + rulesPrivileges: { + rules: { + read: true, + }, + }, + }), +})); + const TEST_ID = 'TEST'; const alertIds = ['id1', 'id2', 'id3']; @@ -51,6 +68,9 @@ const renderCorrelationsTable = ({ describe('CorrelationsDetailsAlertsTable', () => { beforeEach(() => { + useAlertsPrivilegesMock.mockReturnValue({ + hasAlertsRead: true, + }); jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); jest.mocked(usePaginatedAlerts).mockReturnValue({ setPagination: jest.fn(), diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx index 4ed68fccf77d7..308ba813bc807 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx @@ -17,12 +17,14 @@ import { CellTooltipWrapper } from '../../shared/components/cell_tooltip_wrapper import type { DataProvider } from '../../../../../common/types'; import { SeverityBadge } from '../../../../common/components/severity_badge'; import { usePaginatedAlerts } from '../hooks/use_paginated_alerts'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { InvestigateInTimelineButton } from '../../../../common/components/event_details/investigate_in_timeline_button'; import { ExpandablePanel } from '../../../../flyout_v2/shared/components/expandable_panel'; import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations'; import { getDataProvider } from '../../../../common/components/event_details/use_action_cell_data_provider'; import { AlertPreviewButton } from '../../../shared/components/alert_preview_button'; import { PreviewLink } from '../../../shared/components/preview_link'; +import { FlyoutMissingAlertsPrivilege } from '../../../shared/components/flyout_missing_alerts_privilege'; export const TIMESTAMP_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; const dataProviderLimit = 5; @@ -103,6 +105,7 @@ export const CorrelationsDetailsAlertsTable: FC { + const { hasAlertsRead } = useAlertsPrivileges(); const { setPagination, setSorting, @@ -260,13 +263,34 @@ export const CorrelationsDetailsAlertsTable: FC + ) : ( + + data-test-subj={`${dataTestSubj}Table`} + loading={loading || alertsLoading} + tableCaption={i18n.translate( + 'xpack.securitySolution.flyout.left.insights.correlations.correlatedAlertsCaption', + { + defaultMessage: 'Correlated alerts', + } + )} + items={mappedData} + columns={columns ?? defaultColumns} + pagination={paginationConfig} + sorting={sorting} + onChange={onTableChange} + noItemsMessage={noItemsMessage} + /> + ); + return ( 0 ? ( + hasAlertsRead && alertIds && alertIds.length && alertIds.length > 0 ? (
) : null, }} - content={{ error }} + content={{ error: hasAlertsRead ? error : undefined }} expand={{ expandable: true, expandedOnFirstRender: true, }} data-test-subj={dataTestSubj} > - - data-test-subj={`${dataTestSubj}Table`} - loading={loading || alertsLoading} - tableCaption={i18n.translate( - 'xpack.securitySolution.flyout.left.insights.correlations.correlatedAlertsCaption', - { - defaultMessage: 'Correlated alerts', - } - )} - items={mappedData} - columns={columns ?? defaultColumns} - pagination={paginationConfig} - sorting={sorting} - onChange={onTableChange} - noItemsMessage={noItemsMessage} - /> + {panelContent} ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/prevalence_details.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/prevalence_details.test.tsx index 250cf2c3c08fb..98514e8a1acbc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/prevalence_details.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/prevalence_details.test.tsx @@ -153,7 +153,10 @@ describe('PrevalenceDetails', () => { mockUiSettingsGet.mockReturnValue(true); licenseServiceMock.isPlatinumPlus.mockReturnValue(true); jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); - (useUserPrivileges as jest.Mock).mockReturnValue({ timelinePrivileges: { read: true } }); + (useUserPrivileges as jest.Mock).mockReturnValue({ + timelinePrivileges: { read: true }, + rulesPrivileges: { rules: { read: true } }, + }); }); it('should render the table with all data if license is platinum', () => { @@ -281,7 +284,10 @@ describe('PrevalenceDetails', () => { }); it('should render formatted numbers as text if user lacks timeline read privileges', () => { - (useUserPrivileges as jest.Mock).mockReturnValue({ timelinePrivileges: { read: false } }); + (useUserPrivileges as jest.Mock).mockReturnValue({ + timelinePrivileges: { read: false }, + rulesPrivileges: { rules: { read: true } }, + }); (usePrevalence as jest.Mock).mockReturnValue({ loading: false, error: false, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx index adc1fca235f2b..efbb67834e218 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx @@ -22,9 +22,13 @@ import { EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, } from '../../../../flyout_v2/shared/components/test_ids'; import { usePaginatedAlerts } from '../hooks/use_paginated_alerts'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; jest.mock('../../../../flyout_v2/document/hooks/use_fetch_related_alerts_by_ancestry'); jest.mock('../hooks/use_paginated_alerts'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); + +const useAlertsPrivilegesMock = useAlertsPrivileges as jest.Mock; const documentId = 'documentId'; const indices = ['index1']; @@ -50,6 +54,12 @@ const renderRelatedAlertsByAncestry = () => ); describe('', () => { + beforeEach(() => { + useAlertsPrivilegesMock.mockReturnValue({ + hasAlertsRead: true, + }); + }); + it('should render many related alerts correctly', () => { (useFetchRelatedAlertsByAncestry as jest.Mock).mockReturnValue({ loading: false, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx index 9ee9ded03a086..51274a692d608 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx @@ -22,9 +22,13 @@ import { EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, } from '../../../../flyout_v2/shared/components/test_ids'; import { usePaginatedAlerts } from '../hooks/use_paginated_alerts'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; + +const useAlertsPrivilegesMock = useAlertsPrivileges as jest.Mock; jest.mock('../../../../flyout_v2/document/hooks/use_fetch_related_alerts_by_same_source_event'); jest.mock('../hooks/use_paginated_alerts'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); const originalEventId = 'originalEventId'; const scopeId = 'scopeId'; @@ -54,6 +58,12 @@ const renderRelatedAlertsBySameSourceEvent = () => ); describe('', () => { + beforeEach(() => { + useAlertsPrivilegesMock.mockReturnValue({ + hasAlertsRead: true, + }); + }); + it('should render component correctly', () => { (useFetchRelatedAlertsBySameSourceEvent as jest.Mock).mockReturnValue({ loading: false, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx index 4611fe5174dd5..4191285ddf47f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx @@ -22,9 +22,13 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, } from '../../../../flyout_v2/shared/components/test_ids'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; + +const useAlertsPrivilegesMock = useAlertsPrivileges as jest.Mock; jest.mock('../../../../flyout_v2/document/hooks/use_fetch_related_alerts_by_session'); jest.mock('../hooks/use_paginated_alerts'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); const entityId = 'entityId'; const scopeId = 'scopeId'; @@ -50,6 +54,12 @@ const renderRelatedAlertsBySession = () => ); describe('', () => { + beforeEach(() => { + useAlertsPrivilegesMock.mockReturnValue({ + hasAlertsRead: true, + }); + }); + it('should render component correctly', () => { (useFetchRelatedAlertsBySession as jest.Mock).mockReturnValue({ loading: false, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_attacks.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_attacks.test.tsx index dea1edc08126d..1691387013a0b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_attacks.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/related_attacks.test.tsx @@ -23,14 +23,18 @@ import { EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, } from '../../../../flyout_v2/shared/components/test_ids'; import { usePaginatedAlerts } from '../hooks/use_paginated_alerts'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { useDataView } from '../../../../data_view_manager/hooks/use_data_view'; import { getMockDataViewWithMatchedIndices } from '../../../../data_view_manager/mocks/mock_data_view'; import { AttackDetailsPreviewPanelKey } from '../../../attack_details/constants/panel_keys'; jest.mock('../hooks/use_paginated_alerts'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../../../data_view_manager/hooks/use_data_view'); jest.mock('@kbn/expandable-flyout'); +const useAlertsPrivilegesMock = useAlertsPrivileges as jest.Mock; + const attackIds = ['attack-id-1']; const scopeId = 'scopeId'; const eventId = 'event-id'; @@ -56,6 +60,9 @@ const renderRelatedAttacks = () => describe('', () => { beforeEach(() => { + useAlertsPrivilegesMock.mockReturnValue({ + hasAlertsRead: true, + }); jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); jest.mocked(mockFlyoutApi.openPreviewPanel).mockReset(); jest.mocked(useDataView).mockReturnValue({ @@ -132,4 +139,29 @@ describe('', () => { const { getByText } = renderRelatedAttacks(); expect(getByText('No related attacks.')).toBeInTheDocument(); }); + + it('shows the missing alerts privilege message when the user lacks alerting read privilege', () => { + useAlertsPrivilegesMock.mockReturnValue({ + hasAlertsRead: false, + }); + (usePaginatedAlerts as jest.Mock).mockReturnValue({ + loading: false, + error: false, + data: [ + { + _id: 'attack-id-1', + _index: 'index', + fields: { + 'kibana.alert.attack_discovery.title': ['Attack 1'], + }, + }, + ], + }); + + const { getByText, queryByTestId } = renderRelatedAttacks(); + expect(getByText('Privileges required')).toBeInTheDocument(); + expect( + queryByTestId(CORRELATIONS_DETAILS_RELATED_ATTACKS_SECTION_TABLE_TEST_ID) + ).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts index 9f9d031cc0bf2..59f12b920fc50 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts @@ -7,13 +7,17 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useKibana } from '../../../../common/lib/kibana'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { createFindAlerts } from '../services/find_alerts'; import { useFetchAlerts, type UseAlertsQueryParams } from './use_fetch_alerts'; import { createReactQueryWrapper } from '../../../../common/mock'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../services/find_alerts'); +const useAlertsPrivilegesMock = useAlertsPrivileges as jest.Mock; + describe('useFetchAlerts', () => { beforeEach(() => { (useKibana as jest.Mock).mockReturnValue({ @@ -25,6 +29,9 @@ describe('useFetchAlerts', () => { }, }, }); + useAlertsPrivilegesMock.mockReturnValue({ + hasAlertsRead: true, + }); }); it('fetches alerts and handles loading state', async () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.ts index a9ef53b4b8803..f7dbb6521b7ac 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.ts @@ -10,6 +10,7 @@ import { useQuery } from '@kbn/react-query'; import type { AggregationsAggregate, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import { isNumber } from 'lodash'; import { useKibana } from '../../../../common/lib/kibana'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { type AlertsQueryParams, createFindAlerts } from '../services/find_alerts'; export type UseAlertsQueryParams = AlertsQueryParams; @@ -48,6 +49,7 @@ export const useFetchAlerts = ({ const { services: { data: dataService }, } = useKibana(); + const { hasAlertsRead } = useAlertsPrivileges(); const findAlerts = useMemo(() => createFindAlerts(dataService.search), [dataService.search]); @@ -67,6 +69,7 @@ export const useFetchAlerts = ({ }), { keepPreviousData: true, + enabled: hasAlertsRead && (alertIds?.length ?? 0) > 0, } ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/about_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/about_section.tsx index 401f5df445fe4..63ddfa46a89e4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/about_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/about_section.tsx @@ -53,7 +53,7 @@ export const AboutSection = memo(() => { const { telemetry } = useKibana().services; const { dataFormattedForFieldBrowser, eventId, indexName, isRulePreview, scopeId, searchHit } = useDocumentDetailsContext(); - const { rulesPrivileges } = useUserPrivileges(); + const canReadRules = useUserPrivileges().rulesPrivileges.rules.read; const { openPreviewPanel } = useExpandableFlyoutApi(); const { ruleId, ruleName } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); @@ -73,7 +73,7 @@ export const AboutSection = memo(() => { }); const ruleSummaryDisabled = - isEmpty(ruleName) || isEmpty(ruleId) || isRulePreview || !rulesPrivileges?.rules.read; + isEmpty(ruleName) || isEmpty(ruleId) || isRulePreview || !canReadRules; const openRulePreview = useCallback(() => { openPreviewPanel({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx index 22922ea7d6dba..a982ddb6cfd01 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx @@ -77,7 +77,7 @@ describe('', () => { isLoading: false, data: mockUserProfiles, }); - (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsUpdate: true }); (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); (useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!'); @@ -145,8 +145,8 @@ describe('', () => { ); }); - it('should render add assignees button as disabled if user has readonly priviliges', () => { - (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false }); + it('should render add assignees button as disabled if user does not have alerts edit privileges', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsUpdate: false }); const assignees = ['user-id-1', 'user-id-2']; const { getByTestId } = renderAssignees('test-event', assignees); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx index d7ac6738f01f3..3cbb964dda122 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx @@ -81,7 +81,7 @@ export const Assignees = memo( const isPlatinumPlus = useLicense().isPlatinumPlus(); const upsellingMessage = useUpsellingMessage('alert_assignments'); - const { hasIndexWrite } = useAlertsPrivileges(); + const { hasAlertsUpdate } = useAlertsPrivileges(); const setAlertAssignees = useSetAlertAssignees(); const uids = useMemo(() => new Set(assignedUserIds), [assignedUserIds]); @@ -119,7 +119,7 @@ export const Assignees = memo( button={ >; -const writePriveleges: AlertsPriveleges = { hasIndexWrite: true }; +const writePriveleges: AlertsPriveleges = { hasAlertsUpdate: true }; const readPriveleges: AlertsPriveleges = { - hasIndexWrite: false, - hasIndexRead: true, + hasAlertsRead: true, }; jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_dropdown.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_dropdown.test.tsx index 67404bfb566fe..253f933bc1751 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_dropdown.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_dropdown.test.tsx @@ -46,7 +46,7 @@ jest.mock('../../../../common/lib/kibana'); jest.mock( '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', () => ({ - useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true }), + useAlertsPrivileges: jest.fn().mockReturnValue({ hasAlertsUpdate: true }), }) ); jest.mock('../../../../cases/components/use_insert_timeline'); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/context.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/context.tsx index 4357b7461ce05..8ab14ef4c37fb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/context.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/context.tsx @@ -9,14 +9,17 @@ import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-pl import React, { createContext, memo, useContext, useMemo } from 'react'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { TableId } from '@kbn/securitysolution-data-table'; +import { DEFAULT_ALERTS_INDEX } from '../../../../common/constants'; import { useEventDetails } from './hooks/use_event_details'; import { FlyoutError } from '../../shared/components/flyout_error'; import { FlyoutLoading } from '../../shared/components/flyout_loading'; +import { FlyoutMissingAlertsPrivilege } from '../../shared/components/flyout_missing_alerts_privilege'; import type { SearchHit } from '../../../../common/search_strategy'; import { useBasicDataFromDetailsData } from './hooks/use_basic_data_from_details_data'; import type { DocumentDetailsProps } from './types'; import type { GetFieldsData } from './hooks/use_get_fields_data'; import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback'; +import { useAlertsPrivileges } from '../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; export interface DocumentDetailsContext { /** @@ -99,6 +102,8 @@ export const DocumentDetailsProvider = memo( isPreviewMode, children, }: DocumentDetailsProviderProps) => { + const { hasAlertsRead } = useAlertsPrivileges(); + const missingAlertsPrivilege = !hasAlertsRead && indexName?.includes(DEFAULT_ALERTS_INDEX); const { browserFields, dataAsNestedObject, @@ -107,7 +112,7 @@ export const DocumentDetailsProvider = memo( loading, refetchFlyoutData, searchHit, - } = useEventDetails({ eventId: id, indexName }); + } = useEventDetails({ eventId: id, indexName, skip: missingAlertsPrivilege }); const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); const { rule: maybeRule } = useRuleWithFallback(ruleId); @@ -158,6 +163,10 @@ export const DocumentDetailsProvider = memo( return ; } + if (missingAlertsPrivilege) { + return ; + } + if (!contextValue) { return ; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_event_details.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_event_details.ts index ac1e53394b12b..0694ef9cd4eea 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_event_details.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_event_details.ts @@ -46,6 +46,10 @@ export interface UseEventDetailsParams { * Name of the index used in the parent's page */ indexName: string | undefined; + /** + * Whether to skip the event details retrieval + */ + skip?: boolean; } export interface UseEventDetailsResult { @@ -85,6 +89,7 @@ export interface UseEventDetailsResult { export const useEventDetails = ({ eventId, indexName, + skip = false, }: UseEventDetailsParams): UseEventDetailsResult => { const currentSpaceId = useSpaceId(); // TODO Replace getAlertIndexAlias way to retrieving the eventIndex with the GET /_alias @@ -114,7 +119,7 @@ export const useEventDetails = ({ indexName: eventIndex, eventId: eventId ?? '', runtimeMappings, - skip: !eventId, + skip: !eventId || skip, }); const { getFieldsData } = useGetFieldsData({ fieldsData: searchHit?.fields }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.test.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.test.ts index ca748433c1931..e1b4bac0f1d9d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.test.ts @@ -9,6 +9,8 @@ import type { RenderHookResult } from '@testing-library/react'; import { renderHook } from '@testing-library/react'; import type { UseRuleDetailsLinkParams } from './use_rule_details_link'; import { useRuleDetailsLink } from './use_rule_details_link'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { initialUserPrivilegesState } from '../../../../common/components/user_privileges/user_privileges_context'; jest.mock('../../../../common/components/link_to', () => ({ useGetSecuritySolutionUrl: jest @@ -23,9 +25,24 @@ jest.mock('../../../../common/components/link_to', () => ({ getRuleDetailsUrl: jest.fn().mockReturnValue(''), })); +jest.mock('../../../../common/components/user_privileges'); + +const mockUseUserPrivileges = useUserPrivileges as jest.Mock; + describe('useRuleDetailsLink', () => { let hookResult: RenderHookResult; + beforeEach(() => { + jest.clearAllMocks(); + mockUseUserPrivileges.mockReturnValue({ + ...initialUserPrivilegesState(), + rulesPrivileges: { + ...initialUserPrivilegesState().rulesPrivileges, + rules: { read: true, edit: true }, + }, + }); + }); + it('should return null if the ruleId prop is null', () => { const initialProps: UseRuleDetailsLinkParams = { ruleId: null, @@ -37,6 +54,25 @@ describe('useRuleDetailsLink', () => { expect(hookResult.result.current).toBe(null); }); + it('should return null if the user cannot read rules', () => { + mockUseUserPrivileges.mockReturnValue({ + ...initialUserPrivilegesState(), + rulesPrivileges: { + ...initialUserPrivilegesState().rulesPrivileges, + rules: { read: false, edit: false }, + }, + }); + + const initialProps: UseRuleDetailsLinkParams = { + ruleId: 'ruleId', + }; + hookResult = renderHook((props: UseRuleDetailsLinkParams) => useRuleDetailsLink(props), { + initialProps, + }); + + expect(hookResult.result.current).toBe(null); + }); + it('should return timeline in close state', () => { const initialProps: UseRuleDetailsLinkParams = { ruleId: 'ruleId', diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.ts index 6a85b9d8602c3..cbf2a3db27674 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.ts @@ -13,6 +13,7 @@ import { } from '../../../../common/components/link_to'; import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state'; import type { TimelineUrl } from '../../../../timelines/store/model'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; export interface UseRuleDetailsLinkParams { /** @@ -30,8 +31,9 @@ export const useRuleDetailsLink = ( override?: Record ): string | null => { const getSecuritySolutionUrl = useGetSecuritySolutionUrl(override); + const canReadRules = useUserPrivileges().rulesPrivileges.rules.read; - if (!ruleId) return null; + if (!ruleId || !canReadRules) return null; const path = getRuleDetailsUrl(ruleId); let href = getSecuritySolutionUrl({ deepLinkId: SecurityPageName.rules, path }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ease/components/take_action_button.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ease/components/take_action_button.test.tsx index d8424c02d6f9c..385afd1ccd16d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/ease/components/take_action_button.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ease/components/take_action_button.test.tsx @@ -20,7 +20,7 @@ jest.mock('../context'); describe('TakeActionButton', () => { it('should render component with all options', async () => { - (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsUpdate: true }); (useKibana as jest.Mock).mockReturnValue({ services: { cases: { diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_missing_alerts_privilege.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_missing_alerts_privilege.test.tsx new file mode 100644 index 0000000000000..75feba7ae7c42 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_missing_alerts_privilege.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import { FLYOUT_MISSING_ALERTS_PRIVILEGE_TEST_ID } from './test_ids'; +import { FlyoutMissingAlertsPrivilege } from './flyout_missing_alerts_privilege'; + +const mockNoPrivileges = jest.fn( + ({ pageName, docLinkSelector, ...rest }: Record) => ( +
+ + +
+ ) +); + +jest.mock('../../../common/components/no_privileges', () => ({ + NoPrivileges: (props: Record) => mockNoPrivileges(props), +})); + +describe('', () => { + it('renders with the correct test id', () => { + const { getByTestId } = render(); + expect(getByTestId(FLYOUT_MISSING_ALERTS_PRIVILEGE_TEST_ID)).toBeInTheDocument(); + }); + + it('passes the expected pageName and docLinkSelector to NoPrivileges', () => { + const { getByTestId } = render(); + expect(getByTestId(FLYOUT_MISSING_ALERTS_PRIVILEGE_TEST_ID)).toBeInTheDocument(); + expect(mockNoPrivileges).toHaveBeenCalledWith( + expect.objectContaining({ + pageName: 'Alert details', + 'data-test-subj': FLYOUT_MISSING_ALERTS_PRIVILEGE_TEST_ID, + }) + ); + const call = mockNoPrivileges.mock.calls[0][0] as { + docLinkSelector: (links: { siem: { detectionsReq: string } }) => string; + }; + expect(call.docLinkSelector).toBeDefined(); + expect(typeof call.docLinkSelector).toBe('function'); + expect( + call.docLinkSelector({ siem: { detectionsReq: 'https://example.com/detections' } }) + ).toBe('https://example.com/detections'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_missing_alerts_privilege.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_missing_alerts_privilege.tsx new file mode 100644 index 0000000000000..d637a85adf202 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_missing_alerts_privilege.tsx @@ -0,0 +1,34 @@ +/* + * 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 React from 'react'; + +import type { DocLinks } from '@kbn/doc-links'; +import { i18n } from '@kbn/i18n'; +import { FLYOUT_MISSING_ALERTS_PRIVILEGE_TEST_ID } from './test_ids'; +import { NoPrivileges } from '../../../common/components/no_privileges'; + +const alertDetailsPageName = i18n.translate( + 'xpack.securitySolution.flyout.shared.alertDetailsPageName', + { + defaultMessage: 'Alert details', + } +); +const docLinkSelector = (links: DocLinks) => links.siem.detectionsReq; + +/** + * Shown in the alert flyout when the user lacks the Alerts feature (securitySolutionAlertsV1) read privilege + */ +export const FlyoutMissingAlertsPrivilege: React.FC = () => { + return ( + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/preview_link.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/preview_link.test.tsx index 8429a77d93f0d..fea2b4f363e81 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/preview_link.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/preview_link.test.tsx @@ -20,6 +20,8 @@ import { USER_PREVIEW_BANNER } from '../../document_details/right/components/use import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../network_details'; import { RulePreviewPanelKey, RULE_PREVIEW_BANNER } from '../../rule_details/right'; import { createTelemetryServiceMock } from '../../../common/lib/telemetry/telemetry_service.mock'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { initialUserPrivilegesState } from '../../../common/components/user_privileges/user_privileges_context'; const mockedTelemetry = createTelemetryServiceMock(); jest.mock('../../../common/lib/kibana', () => { @@ -37,6 +39,10 @@ jest.mock('@kbn/expandable-flyout', () => ({ ExpandableFlyoutProvider: ({ children }: React.PropsWithChildren<{}>) => <>{children}, })); +jest.mock('../../../common/components/user_privileges'); + +const mockUseUserPrivileges = useUserPrivileges as jest.Mock; + const renderPreviewLink = (field: string, value: string, dataTestSuj?: string) => render( @@ -55,6 +61,17 @@ describe('', () => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); }); + beforeEach(() => { + jest.clearAllMocks(); + mockUseUserPrivileges.mockReturnValue({ + ...initialUserPrivilegesState(), + rulesPrivileges: { + ...initialUserPrivilegesState().rulesPrivileges, + rules: { read: true, edit: true }, + }, + }); + }); + it('should not render a link if field does not have preview', () => { const { queryByTestId } = renderPreviewLink('field', 'value'); expect(queryByTestId(FLYOUT_PREVIEW_LINK_TEST_ID)).not.toBeInTheDocument(); @@ -157,4 +174,43 @@ describe('', () => { ); expect(queryByTestId('rule-link')).not.toBeInTheDocument(); }); + + it('should not render a rule preview link when user cannot read rules', () => { + mockUseUserPrivileges.mockReturnValue({ + ...initialUserPrivilegesState(), + rulesPrivileges: { + ...initialUserPrivilegesState().rulesPrivileges, + rules: { read: false, edit: false }, + }, + }); + + const { queryByTestId, getByText } = render( + + + + ); + + expect(queryByTestId('rule-link')).not.toBeInTheDocument(); + expect(getByText('rule-name')).toBeInTheDocument(); + }); + + it('should still render host preview link when user cannot read rules', () => { + mockUseUserPrivileges.mockReturnValue({ + ...initialUserPrivilegesState(), + rulesPrivileges: { + ...initialUserPrivilegesState().rulesPrivileges, + rules: { read: false, edit: false }, + }, + }); + + const { getByTestId } = renderPreviewLink('host.name', 'host', 'host-link'); + + expect(getByTestId('host-link')).toBeInTheDocument(); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/preview_link.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/preview_link.tsx index 7ea8f1b5ceb0f..88e913462fc29 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/preview_link.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/preview_link.tsx @@ -7,11 +7,13 @@ import type { FC } from 'react'; import React, { useCallback, useMemo } from 'react'; import { EuiLink } from '@elastic/eui'; +import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useKibana } from '../../../common/lib/kibana'; import { FLYOUT_PREVIEW_LINK_TEST_ID } from './test_ids'; import { DocumentEventTypes } from '../../../common/lib/telemetry'; import { getPreviewPanelParams } from '../utils/link_utils'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; interface PreviewLinkProps { /** @@ -60,6 +62,8 @@ export const PreviewLink: FC = ({ }) => { const { openPreviewPanel } = useExpandableFlyoutApi(); const { telemetry } = useKibana().services; + const canReadRules = useUserPrivileges().rulesPrivileges.rules.read; + const shouldShowLink = field === ALERT_RULE_NAME ? canReadRules : true; const previewParams = useMemo( () => @@ -86,7 +90,7 @@ export const PreviewLink: FC = ({ } }, [scopeId, telemetry, openPreviewPanel, previewParams]); - return previewParams ? ( + return shouldShowLink && previewParams ? ( {children ?? value} diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/test_ids.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/test_ids.ts index 94e5bd6f8cc1a..8d1cbb3561dfa 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/test_ids.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/test_ids.ts @@ -12,6 +12,7 @@ export const FLYOUT_LINK_TEST_ID = `${PREFIX}Link` as const; export const FLYOUT_ERROR_TEST_ID = `${PREFIX}Error` as const; export const FLYOUT_LOADING_TEST_ID = `${PREFIX}Loading` as const; +export const FLYOUT_MISSING_ALERTS_PRIVILEGE_TEST_ID = `${PREFIX}MissingAlertsPrivilege` as const; /* Header Navigation */ diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/document_flyout_wrapper.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/document_flyout_wrapper.test.tsx index 91c4a1ced8404..a8747a9aeafc7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/document_flyout_wrapper.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/document_flyout_wrapper.test.tsx @@ -11,17 +11,27 @@ import type { DataTableRecord } from '@kbn/discover-utils'; import { ElasticRequestState } from '@kbn/unified-doc-viewer'; import { useEsDocSearch } from '@kbn/unified-doc-viewer-plugin/public'; import { useDataView } from '../../data_view_manager/hooks/use_data_view'; +import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { TestProviders } from '../../common/mock'; import { DocumentFlyoutWrapper } from './document_flyout_wrapper'; jest.mock('@kbn/unified-doc-viewer-plugin/public'); jest.mock('../../data_view_manager/hooks/use_data_view'); +jest.mock('../../detections/containers/detection_engine/alerts/use_alerts_privileges'); const mockDocumentFlyout = jest.fn((props: unknown) =>
); jest.mock('.', () => ({ DocumentFlyout: (props: unknown) => mockDocumentFlyout(props), })); +const createAlertHit = (): DataTableRecord => + ({ + id: '1', + raw: {}, + flattened: { 'event.kind': 'signal' }, + isAnchor: false, + } as DataTableRecord); + const mockDataView = { hasMatchedIndices: () => true, getIndexPattern: () => 'logs-*', @@ -47,6 +57,7 @@ describe('DocumentFlyoutWrapper', () => { dataView: mockDataView, }); (useEsDocSearch as jest.Mock).mockReturnValue([ElasticRequestState.Loading, null, jest.fn()]); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsRead: true, loading: false }); }); it('fetches clicked document using document id and index', () => { @@ -68,7 +79,7 @@ describe('DocumentFlyoutWrapper', () => { const { getByTestId } = renderDocumentFlyoutWrapper(); - expect(getByTestId('analyzer-event-overview-loading')).toBeInTheDocument(); + expect(getByTestId('document-overview-wrapper-loading')).toBeInTheDocument(); expect(useEsDocSearch).toHaveBeenCalledWith( expect.objectContaining({ skip: true, @@ -76,8 +87,38 @@ describe('DocumentFlyoutWrapper', () => { ); }); + it('renders loading while alerts privileges are loading for an alert', () => { + const alertHit = createAlertHit(); + (useEsDocSearch as jest.Mock).mockReturnValue([ElasticRequestState.Found, alertHit, jest.fn()]); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsRead: false, loading: true }); + + const { getByTestId } = renderDocumentFlyoutWrapper(); + + expect(getByTestId('document-overview-wrapper-loading')).toBeInTheDocument(); + }); + + it('does not render loading when alerts privileges are loading but document is not an alert', () => { + const nonAlertHit: DataTableRecord = { + id: '2', + raw: {}, + flattened: { 'event.kind': 'event' }, + isAnchor: false, + } as DataTableRecord; + (useEsDocSearch as jest.Mock).mockReturnValue([ + ElasticRequestState.Found, + nonAlertHit, + jest.fn(), + ]); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsRead: false, loading: true }); + + const { getByTestId, queryByTestId } = renderDocumentFlyoutWrapper(); + + expect(queryByTestId('document-overview-wrapper-loading')).not.toBeInTheDocument(); + expect(getByTestId('documentFlyoutStub')).toBeInTheDocument(); + }); + it('renders DocumentFlyout when document is found', () => { - const hit = { id: '1' } as DataTableRecord; + const hit = { id: '1', raw: {}, flattened: { 'event.kind': 'event' } } as DataTableRecord; (useEsDocSearch as jest.Mock).mockReturnValue([ElasticRequestState.Found, hit, jest.fn()]); const { getByTestId } = renderDocumentFlyoutWrapper(); @@ -95,7 +136,7 @@ describe('DocumentFlyoutWrapper', () => { const { getByTestId } = renderDocumentFlyoutWrapper(); - expect(getByTestId('analyzer-event-overview-not-found')).toBeInTheDocument(); + expect(getByTestId('document-overview-wrapper-not-found')).toBeInTheDocument(); }); it('renders error state when document fetch fails', () => { @@ -103,6 +144,17 @@ describe('DocumentFlyoutWrapper', () => { const { getByTestId } = renderDocumentFlyoutWrapper(); - expect(getByTestId('analyzer-event-overview-fetch-error')).toBeInTheDocument(); + expect(getByTestId('document-overview-fetch-error')).toBeInTheDocument(); + }); + + it('renders FlyoutMissingAlertsPrivilege when document is an alert and user lacks alerts read privilege', () => { + const alertHit = createAlertHit(); + (useEsDocSearch as jest.Mock).mockReturnValue([ElasticRequestState.Found, alertHit, jest.fn()]); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsRead: false, loading: false }); + + const { getByTestId, queryByTestId } = renderDocumentFlyoutWrapper(); + + expect(getByTestId('noPrivilegesPage')).toBeInTheDocument(); + expect(queryByTestId('documentFlyoutStub')).not.toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/document_flyout_wrapper.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/document_flyout_wrapper.tsx index d05cba483db6e..869bb48a93a7d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/document_flyout_wrapper.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/document_flyout_wrapper.tsx @@ -5,32 +5,46 @@ * 2.0. */ -import React, { memo } from 'react'; -import { EuiCallOut, EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import React, { memo, useMemo } from 'react'; +import { EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ElasticRequestState } from '@kbn/unified-doc-viewer'; import { useEsDocSearch } from '@kbn/unified-doc-viewer-plugin/public'; +import { getFieldValue } from '@kbn/discover-utils'; +import { EVENT_KIND } from '@kbn/rule-data-utils'; + +import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; +import { FlyoutLoading } from '../../flyout/shared/components/flyout_loading'; +import { FlyoutMissingAlertsPrivilege } from '../../flyout/shared/components/flyout_missing_alerts_privilege'; import type { ResolverCellActionRenderer } from '../../resolver/types'; import { useDataView } from '../../data_view_manager/hooks/use_data_view'; import { PageScope } from '../../data_view_manager/constants'; +import { EventKind } from './constants/event_kinds'; import { DocumentFlyout } from '.'; const DATA_VIEW_ERROR = i18n.translate( - 'xpack.securitySolution.analyzer.eventOverviewFlyout.dataViewError', + 'xpack.securitySolution.flyout.document.overviewWrapper.dataViewError', { defaultMessage: 'Unable to retrieve the data view for analyzer.', } ); const DOCUMENT_NOT_FOUND = i18n.translate( - 'xpack.securitySolution.analyzer.eventOverviewFlyout.documentNotFound', + 'xpack.securitySolution.flyout.document.overviewWrapper.documentNotFound', { defaultMessage: 'Cannot find document. No documents match that ID.', } ); +const SOMETHING_WENT_WRONG = i18n.translate( + 'xpack.securitySolution.flyout.document.overviewWrapper.somethingWentWrong', + { + defaultMessage: 'Something went wrong.', + } +); + const FETCH_ERROR = i18n.translate( - 'xpack.securitySolution.analyzer.eventOverviewFlyout.fetchError', + 'xpack.securitySolution.flyout.document.overviewWrapper.fetchError', { defaultMessage: 'Unable to fetch document details.', } @@ -63,6 +77,7 @@ export const DocumentFlyoutWrapper = memo( const isDataViewLoading = status === 'loading' || status === 'pristine'; const isDataViewInvalid = status === 'error' || (status === 'ready' && !dataView.hasMatchedIndices()); + const shouldSkipSearch = isDataViewLoading || isDataViewInvalid || !documentId || !indexName || !dataView; @@ -73,17 +88,24 @@ export const DocumentFlyoutWrapper = memo( skip: shouldSkipSearch, }); - if (isDataViewLoading) { - return ( - - - {' '} - {i18n.translate('xpack.securitySolution.analyzer.eventOverviewFlyout.loading', { - defaultMessage: 'Loading…', - })} - - - ); + const isAlert = useMemo( + () => hit && (getFieldValue(hit, EVENT_KIND) as string) === EventKind.signal, + [hit] + ); + + const { hasAlertsRead, loading: isAlertsPrivilegesLoading } = useAlertsPrivileges(); + const missingAlertsPrivilege = isAlert && !isAlertsPrivilegesLoading && !hasAlertsRead; + + if ( + isDataViewLoading || + (isAlert && isAlertsPrivilegesLoading) || + requestState === ElasticRequestState.Loading + ) { + return ; + } + + if (missingAlertsPrivilege) { + return ; } if (isDataViewInvalid) { @@ -93,7 +115,7 @@ export const DocumentFlyoutWrapper = memo( color="danger" iconType="warning" title={DATA_VIEW_ERROR} - data-test-subj="analyzer-event-overview-data-view-error" + data-test-subj="document-overview-wrapper-data-view-error" /> ); } @@ -109,7 +131,7 @@ export const DocumentFlyoutWrapper = memo( color="danger" iconType="warning" title={DOCUMENT_NOT_FOUND} - data-test-subj="analyzer-event-overview-not-found" + data-test-subj="document-overview-wrapper-not-found" /> ); } @@ -121,20 +143,19 @@ export const DocumentFlyoutWrapper = memo( color="danger" iconType="warning" title={FETCH_ERROR} - data-test-subj="analyzer-event-overview-fetch-error" + data-test-subj="document-overview-fetch-error" /> ); } return ( - - - {' '} - {i18n.translate('xpack.securitySolution.analyzer.eventOverviewFlyout.loadingFallback', { - defaultMessage: 'Loading…', - })} - - + ); } ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/tabs/overview_tab.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/tabs/overview_tab.test.tsx new file mode 100644 index 0000000000000..272d511a5d27f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/tabs/overview_tab.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import type { DataTableRecord } from '@kbn/discover-utils'; +import { useAlertsPrivileges } from '../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; +import { OverviewTab } from './overview_tab'; +import { TestProviders } from '../../../common/mock'; + +jest.mock('../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); + +const createAlertHit = (): DataTableRecord => + ({ + id: '1', + raw: {}, + flattened: { 'event.kind': 'signal' }, + isAnchor: false, + } as DataTableRecord); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders FlyoutMissingAlertsPrivilege when document is an alert and user lacks alerts read privilege', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsRead: false, loading: false }); + const alertHit = createAlertHit(); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('noPrivilegesPage')).toBeInTheDocument(); + }); + + it('renders loading while alerts privileges are loading for an alert', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasAlertsRead: false, loading: true }); + const alertHit = createAlertHit(); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId('document-overview-loading')).toBeInTheDocument(); + expect(queryByTestId('noPrivilegesPage')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/tabs/overview_tab.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/tabs/overview_tab.tsx index 0cf18c4768655..7ace1135d4b99 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/tabs/overview_tab.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/document/tabs/overview_tab.tsx @@ -5,14 +5,20 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import { EuiHorizontalRule } from '@elastic/eui'; import type { DataTableRecord } from '@kbn/discover-utils'; +import { getFieldValue } from '@kbn/discover-utils'; +import { EVENT_KIND } from '@kbn/rule-data-utils'; import { AboutSection } from '../components/about_section'; import { InsightsSection } from '../components/insights_section'; import { InvestigationSection } from '../components/investigation_section'; import { VisualizationsSection } from '../components/visualizations_section'; import type { ResolverCellActionRenderer } from '../../../resolver/types'; +import { FlyoutMissingAlertsPrivilege } from '../../../flyout/shared/components/flyout_missing_alerts_privilege'; +import { FlyoutLoading } from '../../../flyout/shared/components/flyout_loading'; +import { useAlertsPrivileges } from '../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; +import { EventKind } from '../constants/event_kinds'; export interface OverviewTabProps { /** @@ -29,6 +35,22 @@ export interface OverviewTabProps { * Overview view displayed in the document details expandable flyout right section */ export const OverviewTab = memo(({ hit, renderCellActions }: OverviewTabProps) => { + const isAlert = useMemo( + () => (getFieldValue(hit, EVENT_KIND) as string) === EventKind.signal, + [hit] + ); + + const { hasAlertsRead, loading } = useAlertsPrivileges(); + const missingAlertsPrivilege = !loading && !hasAlertsRead && isAlert; + + if (isAlert && loading) { + return ; + } + + if (missingAlertsPrivilege) { + return ; + } + return ( <> diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/notes/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/notes/index.test.tsx index 821b477c3b7b2..4b99bfe8370aa 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/notes/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/notes/index.test.tsx @@ -76,6 +76,11 @@ describe('NotesDetails', () => { useUserPrivilegesMock.mockReturnValue({ notesPrivileges: { crud: true }, timelinePrivileges: { crud: true }, + rulesPrivileges: { + rules: { + read: true, + }, + }, }); (useTimelineConfig as jest.Mock).mockReturnValue(mockTimelineConfig); useIsInSecurityAppMock.mockReturnValue(false); @@ -188,6 +193,11 @@ describe('NotesDetails', () => { useUserPrivilegesMock.mockReturnValue({ notesPrivileges: { crud: false }, timelinePrivileges: { crud: false }, + rulesPrivileges: { + rules: { + read: true, + }, + }, }); const { queryByTestId } = renderNotesDetails(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/shared/components/flyout_provider.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/shared/components/flyout_provider.test.tsx index f26e83688f84f..09724905ade58 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/shared/components/flyout_provider.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout_v2/shared/components/flyout_provider.test.tsx @@ -16,6 +16,9 @@ import type { StartServices } from '../../../types'; import { SECURITY_FEATURE_ID } from '../../../../common/constants'; import { flyoutProviders } from './flyout_provider'; +jest.mock('../../../common/components/user_privileges/user_privileges_context', () => ({ + UserPrivilegesProvider: ({ children }: { children: React.ReactNode }) => children, +})); jest.mock('../../../common/components/discover_in_timeline/provider', () => ({ DiscoverInTimelineContextProvider: ({ children }: { children: React.ReactNode }) => ( <>{children} diff --git a/x-pack/solutions/security/plugins/security_solution/public/helpers.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/helpers.test.tsx index ab21a78c8deff..6d9d77ceba135 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/helpers.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/helpers.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import type { Capabilities } from '@kbn/core/public'; -import { CASES_FEATURE_ID, SECURITY_FEATURE_ID } from '../common/constants'; +import { ALERTS_FEATURE_ID, CASES_FEATURE_ID, SECURITY_FEATURE_ID } from '../common/constants'; import { mockEcsDataWithAlert } from './common/mock'; import { ALERT_RULE_UUID, ALERT_RULE_NAME, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; import { @@ -201,12 +201,33 @@ describe('#isSubPluginAvailable', () => { it('cases plugin should NOT be available if cases privilege is none independently of siem privileges', () => { expect( - isSubPluginAvailable('pluginKey', { + isSubPluginAvailable('cases', { [SECURITY_FEATURE_ID]: { show: false, crud: false }, [CASES_FEATURE_ID]: noCasesCapabilities(), } as unknown as Capabilities) ).toBeFalsy(); }); + + it('attackDiscovery plugin should be available when user has Security access and Alerts read', () => { + expect( + isSubPluginAvailable('attackDiscovery', { + [SECURITY_FEATURE_ID]: { show: true }, + [ALERTS_FEATURE_ID]: { read_alerts: true }, + [CASES_FEATURE_ID]: noCasesCapabilities(), + } as unknown as Capabilities) + ).toBeTruthy(); + }); + + it('attackDiscovery plugin should NOT be available when user has attack-discovery but not Alerts read', () => { + expect( + isSubPluginAvailable('attackDiscovery', { + [SECURITY_FEATURE_ID]: { show: false }, + securitySolutionAttackDiscovery: { 'attack-discovery': true }, + [ALERTS_FEATURE_ID]: { read_alerts: false }, + [CASES_FEATURE_ID]: noCasesCapabilities(), + } as unknown as Capabilities) + ).toBeFalsy(); + }); }); describe('public helpers getField', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/helpers.tsx b/x-pack/solutions/security/plugins/security_solution/public/helpers.tsx index dc51104d17d40..d4cc9e9007cc3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/helpers.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/helpers.tsx @@ -38,7 +38,7 @@ import type { InspectResponse, StartedSubPlugins, StartServices } from './types' import { CASES_SUB_PLUGIN_KEY } from './types'; import { timelineActions } from './timelines/store'; import { TimelineId } from '../common/types'; -import { hasAccessToSecuritySolution } from './helpers_access'; +import { hasAccessToAttackDiscovery, hasAccessToSecuritySolution } from './helpers_access'; export const parseRoute = (location: Pick) => { if (!isEmpty(location.hash)) { @@ -234,6 +234,9 @@ export const isSubPluginAvailable = (pluginKey: string, capabilities: Capabiliti if (CASES_SUB_PLUGIN_KEY === pluginKey) { return capabilities[CASES_FEATURE_ID].read_cases === true; } + if (pluginKey === 'attackDiscovery') { + return hasAccessToAttackDiscovery(capabilities); + } return hasAccessToSecuritySolution(capabilities); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/helpers_access.ts b/x-pack/solutions/security/plugins/security_solution/public/helpers_access.ts index da8bc7fbb17f5..938c90853865c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/helpers_access.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/helpers_access.ts @@ -5,21 +5,35 @@ * 2.0. */ import type { Capabilities } from '@kbn/core/public'; -import { RULES_UI_READ } from '@kbn/security-solution-features/constants'; -import { SECURITY_FEATURE_ID, CASES_FEATURE_ID, RULES_FEATURE_ID } from '../common/constants'; +import { ALERTS_UI_READ, RULES_UI_READ } from '@kbn/security-solution-features/constants'; +import { + SECURITY_FEATURE_ID, + CASES_FEATURE_ID, + RULES_FEATURE_ID, + ALERTS_FEATURE_ID, +} from '../common/constants'; export function hasAccessToSecuritySolution(capabilities: Capabilities): boolean { return Boolean( capabilities[SECURITY_FEATURE_ID]?.show || capabilities.securitySolutionAttackDiscovery?.['attack-discovery'] || - hasAccessToRules(capabilities) + hasAccessToRules(capabilities) || + hasAccessToAlerts(capabilities) ); } +/** Attack Discovery requires Alerts read; used for route-level availability. */ +export function hasAccessToAttackDiscovery(capabilities: Capabilities): boolean { + return hasAccessToSecuritySolution(capabilities) && hasAccessToAlerts(capabilities) === true; +} export function hasAccessToRules(capabilities: Capabilities): boolean { return Boolean(capabilities[RULES_FEATURE_ID]?.[RULES_UI_READ]); } +export function hasAccessToAlerts(capabilities: Capabilities): boolean { + return Boolean(capabilities[ALERTS_FEATURE_ID]?.[ALERTS_UI_READ]); +} + export function hasAccessToCases(capabilities: Capabilities): boolean { return Boolean(capabilities[CASES_FEATURE_ID]?.read_cases); } diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_flyout.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_flyout.test.tsx index 78bae88e7347d..ea867c41a78ad 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_flyout.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_flyout.test.tsx @@ -23,12 +23,14 @@ import { useCloseAlertsFromExceptions } from '../../../../../detection_engine/ru import type { AlertData } from '../../../../../detection_engine/rule_exceptions/utils/types'; import type { Rule } from '../../../../../detection_engine/rule_management/logic'; import { useSignalIndex } from '../../../../../detections/containers/detection_engine/alerts/use_signal_index'; +import { useAlertsPrivileges } from '../../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; jest.mock('../../../../../common/lib/kibana'); jest.mock('../../../../../common/containers/source'); jest.mock('../../../../hooks/artifacts/use_create_artifact'); jest.mock('../../../../../detection_engine/rule_exceptions/logic/use_close_alerts'); jest.mock('../../../../../detections/containers/detection_engine/alerts/use_signal_index'); +jest.mock('../../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); describe('Endpoint exceptions flyout', () => { let mockedContext: AppContextTestRender; @@ -106,6 +108,10 @@ describe('Endpoint exceptions flyout', () => { createDeSignalIndex: jest.fn(), }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ + hasAlertsUpdate: true, + }); + render = (props) => { renderResult = mockedContext.render( = const [bulkCloseAlerts, setBulkCloseAlerts] = useState(false); const [disableBulkClose, setDisableBulkCloseAlerts] = useState(false); const [bulkCloseIndex, setBulkCloseIndex] = useState(); + const { hasAlertsUpdate } = useAlertsPrivileges(); useEffect(() => { if (!isAlertDataLoading && alertData) { @@ -181,22 +183,26 @@ export const EndpointExceptionsFlyout: React.FC = /> )} - - - + {hasAlertsUpdate && ( + <> + + + + + )} diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/alerts/index.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/alerts/index.ts index c18ca51709852..b874979dd2834 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/alerts/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/alerts/index.ts @@ -6,7 +6,7 @@ */ import React from 'react'; -import { RULES_UI_DETECTIONS_PRIVILEGE } from '@kbn/security-solution-features/constants'; +import { ALERTS_UI_DETECTIONS_PRIVILEGE } from '@kbn/security-solution-features/constants'; import type { OnboardingCardConfig } from '../../../../types'; import { OnboardingCardId } from '../../../../constants'; import { ALERTS_CARD_TITLE } from './translations'; @@ -25,5 +25,5 @@ export const alertsCardConfig: OnboardingCardConfig = { './alerts_card' ) ), - capabilitiesRequired: [RULES_UI_DETECTIONS_PRIVILEGE], + capabilitiesRequired: [ALERTS_UI_DETECTIONS_PRIVILEGE], }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header_configs.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header_configs.ts index 3f7266052df6a..d6cf0d2a145db 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header_configs.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header_configs.ts @@ -5,6 +5,7 @@ * 2.0. */ import { + ALERTS_UI_DETECTIONS_PRIVILEGE, RULES_UI_DETECTIONS_PRIVILEGE, RULES_UI_EXTERNAL_DETECTIONS_PRIVILEGE, } from '@kbn/security-solution-features/constants'; @@ -22,7 +23,7 @@ export const defaultHeaderConfig: HeaderConfig = { getTitle: i18n.ONBOARDING_PAGE_TITLE, subTitle: i18n.ONBOARDING_PAGE_SUBTITLE, description: i18n.ONBOARDING_PAGE_DESCRIPTION, - capabilitiesRequired: [RULES_UI_DETECTIONS_PRIVILEGE], + capabilitiesRequired: [RULES_UI_DETECTIONS_PRIVILEGE, ALERTS_UI_DETECTIONS_PRIVILEGE], }; export const headerConfig: HeaderConfig[] = [ diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/config.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/config.ts index 86b201a383e88..8c395213cabb3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/config.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/config.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { + ALERTS_UI_DETECTIONS_PRIVILEGE, RULES_UI_DETECTIONS_PRIVILEGE, RULES_UI_EXTERNAL_DETECTIONS_PRIVILEGE, } from '@kbn/security-solution-features/constants'; @@ -24,7 +25,7 @@ export const onboardingConfig: TopicConfig[] = [ title: i18n.translate('xpack.securitySolution.onboarding.topic.default', { defaultMessage: 'Set up Security', }), - capabilitiesRequired: RULES_UI_DETECTIONS_PRIVILEGE, + capabilitiesRequired: [RULES_UI_DETECTIONS_PRIVILEGE, ALERTS_UI_DETECTIONS_PRIVILEGE], body: defaultBodyConfig, }, { diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts index bbd373d79bb79..7533cb60ed638 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { + ALERTS_UI_READ_PRIVILEGE, RULES_UI_READ_PRIVILEGE, SECURITY_UI_SHOW_PRIVILEGE, } from '@kbn/security-solution-features/constants'; @@ -18,7 +19,7 @@ export const onboardingLinks: LinkItem = { id: SecurityPageName.landing, title: GETTING_STARTED, path: ONBOARDING_PATH, - capabilities: [SECURITY_UI_SHOW_PRIVILEGE, RULES_UI_READ_PRIVILEGE], + capabilities: [SECURITY_UI_SHOW_PRIVILEGE, RULES_UI_READ_PRIVILEGE, ALERTS_UI_READ_PRIVILEGE], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.getStarted', { defaultMessage: 'Getting started', diff --git a/x-pack/solutions/security/plugins/security_solution/public/one_discover/alert_flyout_header_component/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/one_discover/alert_flyout_header_component/index.test.tsx index b29c39d69d9b4..bb129171f2abf 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/one_discover/alert_flyout_header_component/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/one_discover/alert_flyout_header_component/index.test.tsx @@ -18,6 +18,10 @@ import type { StartServices } from '../../types'; const mockDocumentHeader = jest.fn((_props: unknown) =>
{'MockDocumentHeader'}
); +jest.mock('../../common/components/user_privileges/user_privileges_context', () => ({ + UserPrivilegesProvider: ({ children }: { children: React.ReactNode }) => children, +})); + jest.mock('../../flyout_v2/document/header', () => ({ Header: (props: unknown) => mockDocumentHeader(props), })); diff --git a/x-pack/solutions/security/plugins/security_solution/public/one_discover/alert_flyout_overview_tab_component/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/one_discover/alert_flyout_overview_tab_component/index.test.tsx index bfededba662b6..60855d19264cb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/one_discover/alert_flyout_overview_tab_component/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/one_discover/alert_flyout_overview_tab_component/index.test.tsx @@ -24,6 +24,9 @@ jest.mock('../../flyout_v2/document/tabs/overview_tab', () => ({ OverviewTab: () =>
{'MockOverviewTab'}
, })); +jest.mock('../../common/components/user_privileges/user_privileges_context', () => ({ + UserPrivilegesProvider: ({ children }: { children: React.ReactNode }) => children, +})); jest.mock('../../common/components/discover_in_timeline/provider', () => ({ DiscoverInTimelineContextProvider: ({ children }: { children: React.ReactNode }) => ( <>{children} diff --git a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx index a5afe09aa1fab..ad0bf2907a584 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx @@ -12,17 +12,20 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import { SecurityPageName } from '../../../../../common/constants'; import { TestProviders } from '../../../../common/mock'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; import type { RuleAlertsTableProps } from './rule_alerts_table'; import { RuleAlertsTable } from './rule_alerts_table'; import type { RuleAlertsItem, UseRuleAlertsItems } from './use_rule_alerts_items'; const mockGetAppUrl = jest.fn(); +const mockNavigateTo = jest.fn(); jest.mock('../../../../common/lib/kibana/hooks', () => { const original = jest.requireActual('../../../../common/lib/kibana/hooks'); return { ...original, useNavigation: () => ({ getAppUrl: mockGetAppUrl, + navigateTo: mockNavigateTo, }), }; }); @@ -53,6 +56,7 @@ const mockUseRuleAlertsItemsReturn = (param: Partial) jest.mock('./use_rule_alerts_items', () => ({ useRuleAlertsItems: () => mockUseRuleAlertsItems(), })); +jest.mock('../../../../common/components/user_privileges'); const defaultProps: RuleAlertsTableProps = { signalIndexName: '', @@ -140,7 +144,7 @@ describe('RuleAlertsTable', () => { expect(result.getByTestId('severityRuleAlertsTable-severity')).toHaveTextContent('High'); }); - it('should generate the table items links', () => { + it('generates a disabled rule link when the user does not have read rule permissions', () => { const linkUrl = '/fake/link'; mockGetAppUrl.mockReturnValue(linkUrl); mockUseRuleAlertsItemsReturn({ items }); @@ -156,7 +160,33 @@ describe('RuleAlertsTable', () => { path: `id/${items[0].id}`, }); - expect(result.getByTestId('severityRuleAlertsTable-name')).toHaveAttribute('href', linkUrl); + expect(result.getByTestId('severityRuleAlertsTable-name')).toBeDisabled(); + }); + + it('generates a rule link when the user can read rules', () => { + const linkUrl = '/fake/link'; + mockGetAppUrl.mockReturnValue(linkUrl); + mockUseRuleAlertsItemsReturn({ items }); + (useUserPrivileges as jest.Mock).mockReturnValue({ + rulesPrivileges: { + rules: { read: true }, + }, + }); + + const result = render( + + + + ); + + expect(mockGetAppUrl).toBeCalledWith({ + deepLinkId: SecurityPageName.rules, + path: `id/${items[0].id}`, + }); + + fireEvent.click(result.getByTestId('severityRuleAlertsTable-name')); + + expect(mockNavigateTo).toHaveBeenCalledWith({ url: linkUrl }); }); it('should open timeline with filters when total alerts is clicked', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx index 33dfa3cc6ebc4..616015a4251db 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx @@ -40,6 +40,7 @@ import { FormattedCount } from '../../../../common/components/formatted_number'; import { CellActionsMode, SecurityCellActions } from '../../../../common/components/cell_actions'; import { useGlobalFilterQuery } from '../../../../common/hooks/use_global_filter_query'; import { useRiskSeverityColors } from '../../../../common/utils/risk_color_palette'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; export interface RuleAlertsTableProps { signalIndexName: string | null; @@ -59,6 +60,7 @@ export const useGetTableColumns: GetTableColumns = ({ navigateTo, openRuleInAlertsPage, }) => { + const canReadRules = useUserPrivileges().rulesPrivileges.rules.read; const severityColors = useRiskSeverityColors(); return useMemo( () => [ @@ -74,10 +76,9 @@ export const useGetTableColumns: GetTableColumns = ({ content={name} anchorClassName="eui-textTruncate" > - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} { if (ev) { ev.preventDefault(); @@ -133,7 +134,7 @@ export const useGetTableColumns: GetTableColumns = ({ ), }, ], - [getAppUrl, navigateTo, openRuleInAlertsPage, severityColors] + [canReadRules, getAppUrl, navigateTo, openRuleInAlertsPage, severityColors] ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/overview/pages/overview.test.tsx index 951fe06161065..359d4bded2496 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -25,6 +25,7 @@ import { initialUserPrivilegesState } from '../../common/components/user_privile import type { EndpointPrivileges } from '../../../common/endpoint/types'; import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; import { useRiskScore } from '../../entity_analytics/api/hooks/use_risk_score'; +import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; const mockNavigateToApp = jest.fn(); jest.mock('../../common/components/empty_prompt'); @@ -67,22 +68,8 @@ jest.mock('../../common/components/search_bar', () => ({ jest.mock('../../common/components/query_bar', () => ({ QueryBar: () => null, })); -jest.mock('../../common/components/user_privileges', () => { - return { - ...jest.requireActual('../../common/components/user_privileges'), - useUserPrivileges: jest.fn(() => { - return { - listPrivileges: { loading: false, error: undefined, result: undefined }, - detectionEnginePrivileges: { loading: false, error: undefined, result: undefined }, - endpointPrivileges: { - loading: false, - canAccessEndpointManagement: true, - canAccessFleet: true, - }, - }; - }), - }; -}); +jest.mock('../../common/components/user_privileges'); +jest.mock('../../detections/containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../common/containers/local_storage/use_messages_storage'); jest.mock('../containers/overview_cti_links'); @@ -113,6 +100,19 @@ jest.mock('../../sourcerer/containers', () => ({ }), })); +const defaultAlertsPrivileges = { + hasAlertsAll: true, + hasAlertsRead: true, + hasEncryptionKey: true, + hasIndexManage: true, + hasIndexMaintenance: true, + hasIndexRead: true, + hasIndexWrite: true, + hasIndexUpdateDelete: true, + isAuthenticated: true, + loading: false, +}; + const endpointNoticeMessage = (hasMessageValue: boolean) => { return { hasMessage: () => hasMessageValue, @@ -122,8 +122,10 @@ const endpointNoticeMessage = (hasMessageValue: boolean) => { clearAllMessages: () => undefined, }; }; + const mockUseSourcererDataView = useSourcererDataView as jest.Mock; const mockUseUserPrivileges = useUserPrivileges as jest.Mock; +const mockUseAlertsPrivileges = useAlertsPrivileges as jest.Mock; const mockUseFetchIndex = useFetchIndex as jest.Mock; const mockUseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; @@ -142,6 +144,7 @@ describe('Overview', () => { beforeEach(() => { mockUseUserPrivileges.mockReturnValue(loadedUserPrivilegesState()); + mockUseAlertsPrivileges.mockReturnValue(defaultAlertsPrivileges); mockUseFetchIndex.mockReturnValue([ false, { @@ -150,14 +153,7 @@ describe('Overview', () => { ]); }); - afterAll(() => { - mockUseUserPrivileges.mockReset(); - }); - describe('rendering', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); test('it DOES NOT render the Getting started text when an index is available', () => { mockUseSourcererDataView.mockReturnValue({ selectedPatterns: [], diff --git a/x-pack/solutions/security/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/solutions/security/plugins/security_solution/public/overview/pages/overview.tsx index 844494b6b8b9b..04d9411e118b1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/overview/pages/overview.tsx @@ -90,7 +90,7 @@ const OverviewComponent = () => { const { endpointPrivileges: { canAccessFleet }, } = useUserPrivileges(); - const { hasIndexRead, hasAlertsRead } = useAlertsPrivileges(); + const { hasAlertsRead } = useAlertsPrivileges(); const { tiDataSources: allTiDataSources, isInitiallyLoaded: isTiLoaded } = useAllTiDataSources(); if (newDataViewPickerEnabled && status === 'pristine') { @@ -129,7 +129,7 @@ const OverviewComponent = () => { - {hasIndexRead && hasAlertsRead && ( + {hasAlertsRead && ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.test.tsx index bdf0de1e5372f..8c980d1dbb9c0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.test.tsx @@ -163,6 +163,7 @@ describe('AIValue', () => { hasIndexUpdateDelete: false, hasAlertsRead: false, hasAlertsAll: false, + hasAlertsUpdate: false, loading: false, isAuthenticated: true, hasEncryptionKey: true, diff --git a/x-pack/solutions/security/plugins/security_solution/public/rules/jest.config.js b/x-pack/solutions/security/plugins/security_solution/public/rules/jest.config.js new file mode 100644 index 0000000000000..b6ec5b94190aa --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/rules/jest.config.js @@ -0,0 +1,19 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../../..', + roots: ['/x-pack/solutions/security/plugins/security_solution/public/rules'], + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/solutions/security/plugins/security_solution/public/rules', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/solutions/security/plugins/security_solution/public/rules/**/*.{ts,tsx}', + ], + moduleNameMapper: require('../../server/__mocks__/module_name_map'), +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/rules/routes.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/rules/routes.test.tsx new file mode 100644 index 0000000000000..80e328d0d5180 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/rules/routes.test.tsx @@ -0,0 +1,171 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import { MemoryRouter, Route } from '@kbn/shared-ux-router'; +import { RuleDetailsRedirect, RuleDetailsTabGuard } from './routes'; +import { useUserPrivileges } from '../common/components/user_privileges'; +import { useEndpointExceptionsCapability } from '../exceptions/hooks/use_endpoint_exceptions_capability'; +import { RuleDetailTabs } from '../detection_engine/rule_details_ui/pages/rule_details/use_rule_details_tabs'; + +jest.mock('../common/components/user_privileges'); +jest.mock('../exceptions/hooks/use_endpoint_exceptions_capability'); +// Mock RuleDetailsPage to display the current tab from route params +jest.mock('../detection_engine/rule_details_ui/pages/rule_details', () => { + // import useParams directly from react-router-dom because the wrapper @kbn/shared-ux-router does not expose it + const useParams = jest.requireActual('react-router-dom').useParams; + return { + RuleDetailsPage: () => { + const { tabName } = useParams(); + return
; + }, + }; +}); + +const mockUseUserPrivileges = useUserPrivileges as jest.MockedFunction; +const mockUseEndpointExceptionsCapability = useEndpointExceptionsCapability as jest.MockedFunction< + typeof useEndpointExceptionsCapability +>; + +const ruleId = 'test-rule-id'; + +describe('RuleDetailsRedirect', () => { + const doRender = (initialPath: string): { pathname: string; search: string } => { + let pathname = ''; + let search = ''; + render( + + + + + { + pathname = location.pathname; + search = location.search; + return null; + }} + /> + + ); + return { + pathname, + search, + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('redirects to the correct path with default landing tab', () => { + const { pathname } = doRender(`/rules/id/${ruleId}`); + expect(pathname).toBe(`/rules/id/${ruleId}/${RuleDetailTabs.overview}`); + }); + + it('preserves query parameters during redirect', () => { + const { pathname, search } = doRender(`/rules/id/${ruleId}?foo=bar&baz=qux`); + expect(pathname).toBe(`/rules/id/${ruleId}/${RuleDetailTabs.overview}`); + expect(search).toBe('?foo=bar&baz=qux'); + }); +}); + +describe('RuleDetailsTabGuard', () => { + const doRender = (initialPath: string) => { + const renderResult = render( + + + + + + ); + + const getExpectedLandingTab = (tabName: string) => + renderResult.getByTestId(`ruleDetailsPage-${tabName}`); + + return { getExpectedLandingTab }; + }; + + const defaultPrivileges = { + alertsPrivileges: { alerts: { read: true } }, + rulesPrivileges: { exceptions: { read: true } }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseUserPrivileges.mockReturnValue( + defaultPrivileges as ReturnType + ); + mockUseEndpointExceptionsCapability.mockReturnValue(true); + }); + + describe('when user has access to a tab', () => { + it('renders RuleDetailsPage for alerts tab when user can read alerts', () => { + const { getExpectedLandingTab } = doRender(`/rules/id/${ruleId}/${RuleDetailTabs.alerts}`); + expect(getExpectedLandingTab(RuleDetailTabs.alerts)).toBeInTheDocument(); + }); + + it('renders RuleDetailsPage for exceptions tab when user can read exceptions', () => { + const { getExpectedLandingTab } = doRender( + `/rules/id/${ruleId}/${RuleDetailTabs.exceptions}` + ); + expect(getExpectedLandingTab(RuleDetailTabs.exceptions)).toBeInTheDocument(); + }); + + it('renders RuleDetailsPage for endpoint exceptions when user can read endpoint exceptions', () => { + const { getExpectedLandingTab } = doRender( + `/rules/id/${ruleId}/${RuleDetailTabs.endpointExceptions}` + ); + expect(getExpectedLandingTab(RuleDetailTabs.endpointExceptions)).toBeInTheDocument(); + }); + + it('renders RuleDetailsPage for execution results tab', () => { + const { getExpectedLandingTab } = doRender( + `/rules/id/${ruleId}/${RuleDetailTabs.executionResults}` + ); + expect(getExpectedLandingTab(RuleDetailTabs.executionResults)).toBeInTheDocument(); + }); + + it('renders RuleDetailsPage for execution events tab', () => { + const { getExpectedLandingTab } = doRender( + `/rules/id/${ruleId}/${RuleDetailTabs.executionEvents}` + ); + expect(getExpectedLandingTab(RuleDetailTabs.executionEvents)).toBeInTheDocument(); + }); + }); + + describe('when user does not have access to a tab', () => { + const defaultLandingTab = RuleDetailTabs.overview; + beforeEach(() => { + mockUseUserPrivileges.mockReturnValue({ + alertsPrivileges: { alerts: { read: false } }, + rulesPrivileges: { exceptions: { read: false } }, + } as ReturnType); + mockUseEndpointExceptionsCapability.mockReturnValue(false); + }); + + it('redirects to the default landing tab when user does not have access to alerts', () => { + const { getExpectedLandingTab } = doRender(`/rules/id/${ruleId}/${RuleDetailTabs.alerts}`); + expect(getExpectedLandingTab(defaultLandingTab)).toBeInTheDocument(); + }); + + it('redirects to the default landing tab when user does not have access to exceptions', () => { + const { getExpectedLandingTab } = doRender( + `/rules/id/${ruleId}/${RuleDetailTabs.exceptions}` + ); + expect(getExpectedLandingTab(defaultLandingTab)).toBeInTheDocument(); + }); + + it('redirects to the default landing tab when user does not have access to endpoint exceptions', () => { + const { getExpectedLandingTab } = doRender( + `/rules/id/${ruleId}/${RuleDetailTabs.endpointExceptions}` + ); + expect(getExpectedLandingTab(defaultLandingTab)).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/rules/routes.tsx b/x-pack/solutions/security/plugins/security_solution/public/rules/routes.tsx index de8b132b43683..5ede8c4062573 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/rules/routes.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/rules/routes.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React, { useMemo } from 'react'; -import { Redirect } from 'react-router-dom'; +import { Redirect, useLocation, useParams } from 'react-router-dom'; import { Routes, Route } from '@kbn/shared-ux-router'; import type { Capabilities } from '@kbn/core-capabilities-common'; @@ -47,6 +47,58 @@ import { RuleDetailTabs } from '../detection_engine/rule_details_ui/pages/rule_d import { withSecurityRoutePageWrapper } from '../common/components/security_route_page_wrapper'; import { hasCapabilities } from '../common/lib/capabilities'; import { useKibana, useUiSetting$ } from '../common/lib/kibana/kibana_react'; +import { useUserPrivileges } from '../common/components/user_privileges'; +import { useEndpointExceptionsCapability } from '../exceptions/hooks/use_endpoint_exceptions_capability'; +import { getRuleDetailsTabUrl } from '../common/components/link_to/redirect_to_detection_engine'; + +/** + * Component to redirect to rule details with the appropriate landing tab. + * This is a separate component because hooks can only be called at the top level of a React component. + */ +export const RuleDetailsRedirect: React.FC = () => { + const { detailName } = useParams<{ detailName: string }>(); + const location = useLocation(); + const defaultLandingPageWithTab = getRuleDetailsTabUrl(detailName, RuleDetailTabs.overview); + + return ( + + ); +}; + +export const RuleDetailsTabGuard: React.FC = () => { + const { tabName } = useParams<{ detailName: string; tabName: string }>(); + const { alertsPrivileges, rulesPrivileges } = useUserPrivileges(); + const canReadEndpointExceptions = useEndpointExceptionsCapability('showEndpointExceptions'); + + const canReadAlerts = alertsPrivileges.alerts.read; + const canReadExceptions = rulesPrivileges.exceptions.read; + + const canAccessTab = (() => { + switch (tabName) { + case RuleDetailTabs.alerts: + return canReadAlerts; + case RuleDetailTabs.exceptions: + return canReadExceptions; + case RuleDetailTabs.endpointExceptions: + return canReadEndpointExceptions; + default: + return true; + } + })(); + + // Redirect if no access to the requested tab + if (!canAccessTab) { + return ; + } + + return ; +}; interface Features { deHealthUIEnabled: boolean; @@ -64,7 +116,7 @@ const getRulesSubRoutes = ( path: endpointExceptionsTabEnabled ? `/rules/id/:detailName/:tabName(${RuleDetailTabs.overview}|${RuleDetailTabs.alerts}|${RuleDetailTabs.exceptions}|${RuleDetailTabs.endpointExceptions}|${RuleDetailTabs.executionResults}|${RuleDetailTabs.executionEvents})` : `/rules/id/:detailName/:tabName(${RuleDetailTabs.overview}|${RuleDetailTabs.alerts}|${RuleDetailTabs.exceptions}|${RuleDetailTabs.executionResults}|${RuleDetailTabs.executionEvents})`, - main: RuleDetailsPage, + main: RuleDetailsTabGuard, exact: true, }, { @@ -152,24 +204,9 @@ const RulesContainerComponent: React.FC = () => { return ( - ( - - )} - /> + + + diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.test.tsx new file mode 100644 index 0000000000000..f3a4945dc5872 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.test.tsx @@ -0,0 +1,111 @@ +/* + * 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 React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RenderRuleName } from './formatted_field_helpers'; +import { TestProviders } from '../../../../../common/mock'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; +import { useKibana } from '../../../../../common/lib/kibana'; + +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../../common/components/link_to'); +jest.mock('../../../../../common/components/user_privileges'); + +const useUserPrivilegesMock = useUserPrivileges as jest.Mock; +const useKibanaMock = useKibana as jest.Mock; + +const mockNavigateToApp = jest.fn(); + +const defaultProps = { + fieldName: 'kibana.alert.rule.name', + linkValue: 'rule-id-123', + value: 'Test Rule Name', +}; + +describe('RenderRuleName', () => { + beforeEach(() => { + jest.clearAllMocks(); + useKibanaMock.mockReturnValue({ + services: { + application: { + navigateToApp: mockNavigateToApp, + getUrlForApp: jest.fn().mockReturnValue('/app/security/rules/id/rule-id-123'), + }, + }, + }); + }); + + describe('link rendering based on rules read permission', () => { + describe('when the user has read rule permissions', () => { + beforeEach(() => { + useUserPrivilegesMock.mockReturnValue({ + rulesPrivileges: { + rules: { read: true, edit: false }, + exceptions: { read: false, crud: false }, + }, + }); + + render( + + + + ); + }); + it('renders the rule name as a link', () => { + const element = screen.getByTestId('ruleName'); + expect(element).toBeInTheDocument(); + expect(element.tagName).toBe('A'); + expect(element).toHaveTextContent('Test Rule Name'); + }); + + it('navigates to rule details when clicking the link', () => { + const element = screen.getByTestId('ruleName'); + fireEvent.click(element); + + expect(mockNavigateToApp).toHaveBeenCalledTimes(1); + expect(mockNavigateToApp).toHaveBeenCalledWith( + 'securitySolutionUI', + expect.objectContaining({ + deepLinkId: 'rules', + path: expect.stringContaining('rule-id-123'), + }) + ); + }); + }); + + describe('when the user does not have read rule permissions', () => { + beforeEach(() => { + useUserPrivilegesMock.mockReturnValue({ + rulesPrivileges: { + rules: { read: false, edit: false }, + exceptions: { read: false, crud: false }, + }, + }); + + render( + + + + ); + }); + it('renders the rule name as plain text', () => { + const element = screen.getByTestId('ruleName'); + expect(element).toBeInTheDocument(); + expect(element.tagName).toBe('SPAN'); + expect(element).toHaveTextContent('Test Rule Name'); + }); + + it('does not navigate when clicking rule name', () => { + const element = screen.getByTestId('ruleName'); + fireEvent.click(element); + + expect(mockNavigateToApp).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx index e0ed776219677..bcf32aa743292 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx @@ -26,6 +26,7 @@ import { LinkAnchor } from '../../../../../common/components/links'; import { GenericLinkButton } from '../../../../../common/components/links/helpers'; import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context'; import { RulePanelKey } from '../../../../../flyout/rule_details/right'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; const EventModuleFlexItem = styled(EuiFlexItem)` width: 100%; @@ -63,6 +64,7 @@ export const RenderRuleName: React.FC = ({ const ruleId = linkValue; const { search } = useFormatUrl(SecurityPageName.rules); const { navigateToApp, getUrlForApp } = useKibana().services.application; + const canReadRules = useUserPrivileges().rulesPrivileges.rules.read; const isInTimelineContext = ruleName && eventContext?.enableHostDetailsFlyout && eventContext?.timelineID; @@ -165,10 +167,12 @@ export const RenderRuleName: React.FC = ({ openInNewTab, ]); - if (isString(value) && ruleName.length > 0 && ruleId != null) { + const shouldShowLink = canReadRules && isString(value) && ruleName.length > 0 && ruleId != null; + + if (shouldShowLink) { return link; } else if (value != null) { - return <>{value}; + return {value}; } return getEmptyTagValue(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/containers/details/index.tsx index bc06703649187..ac9bced5869b3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -137,5 +137,11 @@ export const useTimelineEventsDetails = ({ }; }, [timelineDetailsRequest, timelineDetailsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + } + }, [skip]); + return [loading, timelineDetailsResponse, rawEventData, ecsData, refetch.current]; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/capabilities/alerts_capabilities_switcher.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/capabilities/alerts_capabilities_switcher.test.ts new file mode 100644 index 0000000000000..b9a422b7726eb --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/capabilities/alerts_capabilities_switcher.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE } from '@kbn/security-solution-features/constants'; +import { setupAlertsCapabilitiesSwitcher } from './alerts_capabilities_switcher'; +import { coreMock, loggingSystemMock } from '@kbn/core/server/mocks'; + +describe('setupAlertsCapabilitiesSwitcher', () => { + const createMockDeps = () => { + const coreSetup = coreMock.createSetup(); + const logger = loggingSystemMock.createLogger(); + + return { + coreSetup, + logger, + getSecurityStart: jest.fn(), + }; + }; + + describe('registerProvider', () => { + it('registers a capability provider once', () => { + const { coreSetup, logger, getSecurityStart } = createMockDeps(); + + setupAlertsCapabilitiesSwitcher({ core: coreSetup, logger, getSecurityStart }); + + expect(coreSetup.capabilities.registerProvider).toHaveBeenCalledTimes(1); + }); + + it('registers deprecated capabilities for all legacy security features', () => { + const { coreSetup, logger, getSecurityStart } = createMockDeps(); + + setupAlertsCapabilitiesSwitcher({ core: coreSetup, logger, getSecurityStart }); + + const [provider] = coreSetup.capabilities.registerProvider.mock.calls[0]; + const capabilities = provider(); + + expect(capabilities).toEqual({ + siem: { + [ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE]: true, + }, + siemV2: { + [ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE]: true, + }, + siemV3: { + [ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE]: true, + }, + siemV4: { + [ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE]: true, + }, + securitySolutionRulesV1: { + [ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE]: true, + }, + securitySolutionRulesV2: { + [ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE]: true, + }, + }); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/capabilities/alerts_capabilities_switcher.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/capabilities/alerts_capabilities_switcher.ts new file mode 100644 index 0000000000000..3329db6afca75 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/capabilities/alerts_capabilities_switcher.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CoreSetup, Logger } from '@kbn/core/server'; +import type { SecurityPluginStart } from '@kbn/security-plugin/server'; +import { + ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE, + SECURITY_FEATURE_ID_V1, + SECURITY_FEATURE_ID_V2, + SECURITY_FEATURE_ID_V3, + SECURITY_FEATURE_ID_V4, + RULES_FEATURE_ID_V1, + RULES_FEATURE_ID_V2, +} from '@kbn/security-solution-features/constants'; + +interface SetupDeps { + core: CoreSetup; + logger: Logger; + getSecurityStart: () => Promise; +} + +/** + * Registers a capability switcher that grants the deprecated alerts update capability + * to users who have read access on deprecated features (siem, siemV2, siemV3, siemV4, securityRulesV1 and securityRulesV2). + * + * This maintains backward compatibility: users with deprecated feature privileges can still + * trigger alert updates from the UI, while users with only the new alerts feature 'read' privilege cannot. + */ +export const setupAlertsCapabilitiesSwitcher = ({ core, logger, getSecurityStart }: SetupDeps) => { + // Since deprecated UI privileges do not appear in the latest version of the alerts feature, we need to register them here. + const deprecatedFeatures = [ + SECURITY_FEATURE_ID_V1, + SECURITY_FEATURE_ID_V2, + SECURITY_FEATURE_ID_V3, + SECURITY_FEATURE_ID_V4, + RULES_FEATURE_ID_V1, + RULES_FEATURE_ID_V2, + ]; + + core.capabilities.registerProvider(() => + deprecatedFeatures.reduce((acc, featureId) => { + acc[featureId] = { + // Even though we set it to true here, the privilege is only granted + // if it is explicitly listed in the ui privileges in the feature configuration + [ALERTS_UI_UPDATE_DEPRECATED_PRIVILEGE]: true, + }; + return acc; + }, {} as Record) + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts index f4fb9b15fa573..25f51c9c4fe1d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts @@ -9,7 +9,7 @@ import { merge } from 'lodash/fp'; import { readPrivileges, transformError } from '@kbn/securitysolution-es-utils'; import type { IKibanaResponse } from '@kbn/core/server'; -import { RULES_API_READ } from '@kbn/security-solution-features/constants'; +import { ALERTS_API_READ, RULES_API_READ } from '@kbn/security-solution-features/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { DETECTION_ENGINE_PRIVILEGES_URL } from '../../../../../common/constants'; import { buildSiemResponse } from '../utils'; @@ -25,7 +25,9 @@ export const readPrivilegesRoute = ( access: 'public', security: { authz: { - requiredPrivileges: [{ anyRequired: [RULES_API_READ, 'securitySolution'] }], + requiredPrivileges: [ + { anyRequired: [ALERTS_API_READ, RULES_API_READ, 'securitySolution'] }, + ], }, }, }) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts index 0a3d77fcbf3c4..2cb2a6c03cae1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts @@ -9,7 +9,10 @@ import { get } from 'lodash'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; -import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; +import { + ALERTS_API_ALL, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, +} from '@kbn/security-solution-features/constants'; import { ALERT_CLOSING_REASON_VALIDATION_ERROR } from './translations'; import { DefaultClosingReasonSchema } from '../../../../../common/types'; import { SetAlertsStatusRequestBody } from '../../../../../common/api/detection_engine/signals'; @@ -43,7 +46,9 @@ export const setSignalsStatusRoute = ( access: 'public', security: { authz: { - requiredPrivileges: [ALERTS_API_READ], + requiredPrivileges: [ + { anyRequired: [ALERTS_API_ALL, ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE] }, + ], }, }, }) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts index 77c8384919d49..0cbb0b2c1fab5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts @@ -6,7 +6,10 @@ */ import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; -import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; +import { + ALERTS_API_ALL, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, +} from '@kbn/security-solution-features/constants'; import { SetAlertAssigneesRequestBody } from '../../../../../common/api/detection_engine/alert_assignees'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { @@ -22,8 +25,9 @@ export const setAlertAssigneesRoute = (router: SecuritySolutionPluginRouter) => access: 'public', security: { authz: { - // a t1_analyst, who has read only access, should be able to assign alerts - requiredPrivileges: [ALERTS_API_READ], + requiredPrivileges: [ + { anyRequired: [ALERTS_API_ALL, ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE] }, + ], }, }, }) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts index 0105ae860828b..bd67f1de42714 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts @@ -6,7 +6,10 @@ */ import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; -import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; +import { + ALERTS_API_ALL, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, +} from '@kbn/security-solution-features/constants'; import { SetAlertTagsRequestBody } from '../../../../../common/api/detection_engine/alert_tags'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { @@ -22,7 +25,9 @@ export const setAlertTagsRoute = (router: SecuritySolutionPluginRouter) => { access: 'public', security: { authz: { - requiredPrivileges: [ALERTS_API_READ], + requiredPrivileges: [ + { anyRequired: [ALERTS_API_ALL, ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE] }, + ], }, }, }) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_alert_assignees_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_alert_assignees_route.ts index f93e8d7cf56a0..1f7722f9ea357 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_alert_assignees_route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_alert_assignees_route.ts @@ -8,7 +8,10 @@ import type { IRuleDataClient } from '@kbn/rule-registry-plugin/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; import { ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX } from '@kbn/elastic-assistant-common'; -import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; +import { + ALERTS_API_ALL, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, +} from '@kbn/security-solution-features/constants'; import { SetUnifiedAlertsAssigneesRequestBody } from '../../../../../common/api/detection_engine/unified_alerts'; import type { SecuritySolutionPluginRouter } from '../../../../types'; @@ -25,7 +28,9 @@ export const setUnifiedAlertsAssigneesRoute = ( access: 'internal', security: { authz: { - requiredPrivileges: [ALERTS_API_READ], + requiredPrivileges: [ + { anyRequired: [ALERTS_API_ALL, ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE] }, + ], }, }, }) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_alert_tags_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_alert_tags_route.ts index cd7c9f314f13f..1c4c7eafe4f0a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_alert_tags_route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_alert_tags_route.ts @@ -8,7 +8,10 @@ import type { IRuleDataClient } from '@kbn/rule-registry-plugin/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; import { ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX } from '@kbn/elastic-assistant-common'; -import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; +import { + ALERTS_API_ALL, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, +} from '@kbn/security-solution-features/constants'; import { SetUnifiedAlertsTagsRequestBody } from '../../../../../common/api/detection_engine/unified_alerts'; import type { SecuritySolutionPluginRouter } from '../../../../types'; @@ -25,7 +28,9 @@ export const setUnifiedAlertsTagsRoute = ( access: 'internal', security: { authz: { - requiredPrivileges: [ALERTS_API_READ], + requiredPrivileges: [ + { anyRequired: [ALERTS_API_ALL, ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE] }, + ], }, }, }) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_workflow_status_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_workflow_status_route.ts index b769b0ef4f50b..dc8f8f5259318 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_workflow_status_route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/routes/unified_alerts/set_workflow_status_route.ts @@ -8,7 +8,10 @@ import type { IRuleDataClient } from '@kbn/rule-registry-plugin/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; import { ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX } from '@kbn/elastic-assistant-common'; -import { ALERTS_API_READ } from '@kbn/security-solution-features/constants'; +import { + ALERTS_API_ALL, + ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE, +} from '@kbn/security-solution-features/constants'; import { SetUnifiedAlertsWorkflowStatusRequestBody } from '../../../../../common/api/detection_engine/unified_alerts'; import type { SecuritySolutionPluginRouter } from '../../../../types'; @@ -25,7 +28,9 @@ export const setUnifiedAlertsWorkflowStatusRoute = ( access: 'internal', security: { authz: { - requiredPrivileges: [ALERTS_API_READ], + requiredPrivileges: [ + { anyRequired: [ALERTS_API_ALL, ALERTS_API_UPDATE_DEPRECATED_PRIVILEGE] }, + ], }, }, }) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/mocks.ts index 0aa4f499ac840..b6c601b50f8d5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/mocks.ts @@ -93,6 +93,16 @@ jest.mock('@kbn/security-solution-features/product_features', () => ({ baseKibanaSubFeatureIds: [], subFeaturesMap: new Map(), })), + getRulesV3Feature: jest.fn(() => ({ + baseKibanaFeature: {}, + baseKibanaSubFeatureIds: [], + subFeaturesMap: new Map(), + })), + getAlertsFeature: jest.fn(() => ({ + baseKibanaFeature: {}, + baseKibanaSubFeatureIds: [], + subFeaturesMap: new Map(), + })), })); export const createProductFeaturesServiceMock = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features.ts index 8c5f2b7f41ef3..d2e8a6077886a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features.ts @@ -15,6 +15,8 @@ import type { ProductFeatureParams, ProductFeatureGroup, ProductFeatureKeyType, + ProductFeatureParamsKey, + ProductFeatureParamsSubFeatureId, ProductFeaturesConfiguratorExtensions, ProductFeatureKibanaConfig, } from '@kbn/security-solution-features'; @@ -31,7 +33,12 @@ export class ProductFeatures { this.registeredActions = new Set(); } - public create(featureGroup: ProductFeatureGroup, versions: ProductFeatureParams[]) { + public create< + P extends ProductFeatureParams< + ProductFeatureParamsKey

& ProductFeatureKeyType, + ProductFeatureParamsSubFeatureId

+ > + >(featureGroup: ProductFeatureGroup, versions: P[]) { this.groupVersions.set(featureGroup, versions); } @@ -56,7 +63,7 @@ export class ProductFeatures { for (const featureVersion of featureGroupVersions) { const versionExtensions = versionsExtensions[featureVersion.baseKibanaFeature.id] ?? {}; - const extendedConfig = extendProductFeatureConfigs( + const extendedConfig = extendProductFeatureConfigs( featureVersion.productFeatureConfig ?? {}, allVersionsExtensions, versionExtensions diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts index 6e4c0d0e6c912..74a359f5e9fd3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts @@ -44,6 +44,8 @@ jest.mock('@kbn/security-solution-features/product_features', () => ({ getSecurityV5Feature: () => mockGetFeature(), getRulesFeature: () => mockGetFeature(), getRulesV2Feature: () => mockGetFeature(), + getRulesV3Feature: () => mockGetFeature(), + getAlertsFeature: () => mockGetFeature(), getCasesFeature: () => mockGetFeature(), getCasesV2Feature: () => mockGetFeature(), getCasesV3Feature: () => mockGetFeature(), diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features_service.ts index 2d1f9ec8a8b9f..26b5de767daa3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features_service.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/product_features_service.ts @@ -24,8 +24,10 @@ import { getTimelineFeature, getNotesFeature, getSiemMigrationsFeature, - getRulesV2Feature, getRulesFeature, + getRulesV2Feature, + getRulesV3Feature, + getAlertsFeature, } from '@kbn/security-solution-features/product_features'; import { API_ACTION_PREFIX } from '@kbn/security-solution-features/actions'; import type { ExperimentalFeatures } from '../../../common'; @@ -34,6 +36,7 @@ import { casesProductFeatureParams } from './cases_product_feature_params'; import { rulesSavedObjects, rulesV2SavedObjects, + rulesV3SavedObjects, securityExceptionsSavedObjects, securityNotesSavedObjects, securityTimelineSavedObjects, @@ -89,7 +92,12 @@ export class ProductFeaturesService { ...securityFeatureParams, savedObjects: [...rulesV2SavedObjects, ...securityExceptionsSavedObjects], }), + getRulesV3Feature({ + ...securityFeatureParams, + savedObjects: [...rulesV3SavedObjects, ...securityExceptionsSavedObjects], + }), ]); + this.productFeaturesRegistry.create('alerts', [getAlertsFeature()]); if (!experimentalFeatures.siemMigrationsDisabled) { this.productFeaturesRegistry.create('siemMigrations', [getSiemMigrationsFeature()]); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/security_saved_objects.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/security_saved_objects.ts index a7fca677fde87..a13953ec42836 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/security_saved_objects.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/product_features_service/security_saved_objects.ts @@ -73,5 +73,6 @@ export const securityNotesSavedObjects = notesSavedObjectTypes; export const rulesSavedObjects = ['exception-list', prebuiltRuleAssetType.name]; export const rulesV2SavedObjects = [prebuiltRuleAssetType.name]; +export const rulesV3SavedObjects = [...rulesV2SavedObjects]; export const securityExceptionsSavedObjects = exceptionsSavedObjectTypes; diff --git a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts index 8c6cb04ad2f22..c470bdac8e45e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts @@ -158,6 +158,7 @@ import { } from './lib/trial_companion/services/trial_companion_milestone_service'; import { AIValueReportLocatorDefinition } from '../common/locators/ai_value_report/locator'; import type { TrialCompanionRoutesDeps } from './lib/trial_companion/types'; +import { setupAlertsCapabilitiesSwitcher } from './lib/capabilities/alerts_capabilities_switcher'; import { securityAlertsProfileInitializer } from './lib/anonymization'; export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract'; @@ -693,6 +694,15 @@ export class Plugin implements ISecuritySolutionPlugin { this.registerAgentBuilderAttachmentsAndTools(plugins, core, this.logger); + setupAlertsCapabilitiesSwitcher({ + core, + logger: this.logger, + getSecurityStart: async () => { + const [, startPlugins] = await core.getStartServices(); + return startPlugins.security; + }, + }); + return { setProductFeaturesConfigurator: productFeaturesService.setProductFeaturesConfigurator.bind(productFeaturesService), diff --git a/x-pack/solutions/security/test/security_solution_api_integration/moon.yml b/x-pack/solutions/security/test/security_solution_api_integration/moon.yml index 0bbe881da6717..2caad23c3c2d1 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/moon.yml +++ b/x-pack/solutions/security/test/security_solution_api_integration/moon.yml @@ -67,6 +67,7 @@ dependsOn: - '@kbn/cloud-security-posture-common' - '@kbn/detections-response-ftr-services' - '@kbn/connector-schemas' + - '@kbn/security-solution-features' - '@kbn/spaces-utils' tags: - functional-tests diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/alerts/trial_license_complete_tier/document_level_security.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/alerts/trial_license_complete_tier/document_level_security.ts index 52ad5e4bcaa10..2e6e96bf2965a 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/alerts/trial_license_complete_tier/document_level_security.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/alerts/trial_license_complete_tier/document_level_security.ts @@ -9,6 +9,7 @@ import expect from '@kbn/expect'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL, + ALERTS_FEATURE_ID, RULES_FEATURE_ID, SECURITY_FEATURE_ID, } from '@kbn/security-solution-plugin/common/constants'; @@ -31,6 +32,7 @@ const roleToAccessSecuritySolution = { feature: { [SECURITY_FEATURE_ID]: ['all'], [RULES_FEATURE_ID]: ['all'], + [ALERTS_FEATURE_ID]: ['all'], }, spaces: ['*'], }, @@ -55,6 +57,7 @@ const roleToAccessSecuritySolutionWithDls = { feature: { [SECURITY_FEATURE_ID]: ['all'], [RULES_FEATURE_ID]: ['all'], + [ALERTS_FEATURE_ID]: ['all'], }, spaces: ['*'], }, diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/search_alerts/search_alerts_ess.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/search_alerts/search_alerts_ess.ts index bb04b11ab12d4..b70a8468ccdf3 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/search_alerts/search_alerts_ess.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/search_alerts/search_alerts_ess.ts @@ -19,14 +19,14 @@ import type { FtrProviderContext } from '../../../../../../ftr_provider_context' import { getSimpleQuery } from '../../utils/queries'; import { noKibanaPrivileges, - rulesReadUser, - rulesReadNoAttackIndicesUser, - rulesReadNoDetectionIndicesUser, - rulesReadNoIndicesUser, + alertsReadUser, + alertsReadNoAttackIndicesUser, + alertsReadNoDetectionIndicesUser, + alertsReadNoIndicesUser, } from '../../utils/auth/users'; import { getMissingReadIndexPrivilegesError, - getMissingSecurityKibanaPrivilegesError, + getMissingAlertsReadPrivilegesError, } from '../../utils/privileges_errors'; import { expectedAttackAlerts, expectedDetectionAlerts } from '../../mocks'; @@ -36,10 +36,10 @@ export default ({ getService }: FtrProviderContext) => { describe('@ess Search Alerts - ESS', () => { describe('RBAC', () => { describe('Kibana privileges', () => { - it('should return all alerts with rules read privileges', async () => { + it('should return all alerts with alerts read privileges', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL) - .auth(rulesReadUser.username, rulesReadUser.password) + .auth(alertsReadUser.username, alertsReadUser.password) .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') @@ -49,7 +49,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body.hits.hits).toEqual([...expectedDetectionAlerts, ...expectedAttackAlerts]); }); - it('should not return alerts without rules read privileges', async () => { + it('should not return alerts without alerts read privileges', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL) .auth(noKibanaPrivileges.username, noKibanaPrivileges.password) @@ -60,7 +60,7 @@ export default ({ getService }: FtrProviderContext) => { .expect(403); expect(body).toEqual( - getMissingSecurityKibanaPrivilegesError({ + getMissingAlertsReadPrivilegesError({ routeDetails: `POST ${DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL}`, }) ); @@ -71,7 +71,7 @@ export default ({ getService }: FtrProviderContext) => { it('should not return alerts without index privileges', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL) - .auth(rulesReadNoIndicesUser.username, rulesReadNoIndicesUser.password) + .auth(alertsReadNoIndicesUser.username, alertsReadNoIndicesUser.password) .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') @@ -80,8 +80,8 @@ export default ({ getService }: FtrProviderContext) => { expect(body).toEqual( getMissingReadIndexPrivilegesError({ - username: rulesReadNoIndicesUser.username, - roles: rulesReadNoIndicesUser.roles, + username: alertsReadNoIndicesUser.username, + roles: alertsReadNoIndicesUser.roles, }) ); }); @@ -90,8 +90,8 @@ export default ({ getService }: FtrProviderContext) => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL) .auth( - rulesReadNoDetectionIndicesUser.username, - rulesReadNoDetectionIndicesUser.password + alertsReadNoDetectionIndicesUser.username, + alertsReadNoDetectionIndicesUser.password ) .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) @@ -106,7 +106,7 @@ export default ({ getService }: FtrProviderContext) => { it('should return only detection alerts without attack alerts index privileges', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL) - .auth(rulesReadNoAttackIndicesUser.username, rulesReadNoAttackIndicesUser.password) + .auth(alertsReadNoAttackIndicesUser.username, alertsReadNoAttackIndicesUser.password) .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/search_alerts/search_alerts_serverless.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/search_alerts/search_alerts_serverless.ts index 88673b08502f5..77260b2783d09 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/search_alerts/search_alerts_serverless.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/search_alerts/search_alerts_serverless.ts @@ -20,13 +20,13 @@ import type { FtrProviderContext } from '../../../../../../ftr_provider_context' import { getSimpleQuery } from '../../utils/queries'; import { noKibanaPrivileges, - rulesReadNoAttackIndices, - rulesReadNoDetectionIndices, - rulesReadNoIndices, - rulesRead, + alertsReadNoAttackIndices, + alertsReadNoDetectionIndices, + alertsReadNoIndices, + alertsRead, } from '../../utils/auth/roles'; import { - getMissingSecurityKibanaPrivilegesError, + getMissingAlertsReadPrivilegesError, getServerlessMissingReadIndexPrivilegesErrorPattern, } from '../../utils/privileges_errors'; import { expectedAttackAlerts, expectedDetectionAlerts } from '../../mocks'; @@ -67,8 +67,8 @@ export default ({ getService }: FtrProviderContext) => { describe('RBAC', () => { describe('Kibana privileges', () => { - it('should return all alerts with rules read privileges', async () => { - const testAgent = await utils.createSuperTestWithCustomRole(rulesRead); + it('should return all alerts with alerts read privileges', async () => { + const testAgent = await utils.createSuperTestWithCustomRole(alertsRead); const { body } = await testAgent .post(DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL) @@ -81,7 +81,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body.hits.hits).toEqual([...expectedDetectionAlerts, ...expectedAttackAlerts]); }); - it('should not return alerts without rules read privileges', async () => { + it('should not return alerts without alerts read privileges', async () => { const testAgent = await utils.createSuperTestWithCustomRole(noKibanaPrivileges); const { body } = await testAgent @@ -93,7 +93,7 @@ export default ({ getService }: FtrProviderContext) => { .expect(403); expect(body).toEqual( - getMissingSecurityKibanaPrivilegesError({ + getMissingAlertsReadPrivilegesError({ routeDetails: `POST ${DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL}`, }) ); @@ -102,7 +102,7 @@ export default ({ getService }: FtrProviderContext) => { describe('Elasticsearch privileges', () => { it('should not return alerts without index privileges', async () => { - const testAgent = await utils.createSuperTestWithCustomRole(rulesReadNoIndices); + const testAgent = await utils.createSuperTestWithCustomRole(alertsReadNoIndices); const { body } = await testAgent .post(DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL) @@ -116,7 +116,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('should return only attack alerts without detection alerts index privileges', async () => { - const testAgent = await utils.createSuperTestWithCustomRole(rulesReadNoDetectionIndices); + const testAgent = await utils.createSuperTestWithCustomRole(alertsReadNoDetectionIndices); const { body } = await testAgent .post(DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL) @@ -131,7 +131,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('should return only detection alerts without attack alerts index privileges', async () => { - const testAgent = await utils.createSuperTestWithCustomRole(rulesReadNoAttackIndices); + const testAgent = await utils.createSuperTestWithCustomRole(alertsReadNoAttackIndices); const { body } = await testAgent .post(DETECTION_ENGINE_SEARCH_UNIFIED_ALERTS_URL) diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_assignees/set_alert_assignees_ess.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_assignees/set_alert_assignees_ess.ts index 392b2bb9ff0c9..aba11a8476ce4 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_assignees/set_alert_assignees_ess.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_assignees/set_alert_assignees_ess.ts @@ -16,8 +16,13 @@ import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST, } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../../../ftr_provider_context'; -import { noKibanaPrivileges, rulesReadUser } from '../../utils/auth/users'; -import { getMissingSecurityKibanaPrivilegesError } from '../../utils/privileges_errors'; +import { + noKibanaPrivileges, + alertsReadUser, + alertsAllUser, + alertsUpdateLegacyUser, +} from '../../utils/auth/users'; +import { getMissingAlertsUpdatePrivilegesError } from '../../utils/privileges_errors'; export default ({ getService }: FtrProviderContext) => { const supertestWithoutAuth = getService('supertestWithoutAuth'); @@ -25,10 +30,10 @@ export default ({ getService }: FtrProviderContext) => { describe('@ess Set Alert Assignees - ESS', () => { describe('RBAC', () => { describe('Kibana privileges', () => { - it('should update assignees with rules read privileges', async () => { + it('should update assignees with alerts all privileges', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_ASSIGNEES_URL) - .auth(rulesReadUser.username, rulesReadUser.password) + .auth(alertsAllUser.username, alertsAllUser.password) .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') @@ -44,7 +49,26 @@ export default ({ getService }: FtrProviderContext) => { expect(body).toHaveProperty('updated'); }); - it('should not update assignees without rules read privileges', async () => { + it('should update assignees with legacy alerts update privileges', async () => { + const { body } = await supertestWithoutAuth + .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_ASSIGNEES_URL) + .auth(alertsUpdateLegacyUser.username, alertsUpdateLegacyUser.password) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ + ids: ['test-id'], + assignees: { + add: ['user1'], + remove: [], + }, + }) + .expect(200); + + expect(body).toHaveProperty('updated'); + }); + + it('should not update assignees without alerts read privileges', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_ASSIGNEES_URL) .auth(noKibanaPrivileges.username, noKibanaPrivileges.password) @@ -61,7 +85,30 @@ export default ({ getService }: FtrProviderContext) => { .expect(403); expect(body).toEqual( - getMissingSecurityKibanaPrivilegesError({ + getMissingAlertsUpdatePrivilegesError({ + routeDetails: `POST ${DETECTION_ENGINE_SET_UNIFIED_ALERTS_ASSIGNEES_URL}`, + }) + ); + }); + + it('should not update assignees with alerts read privileges', async () => { + const { body } = await supertestWithoutAuth + .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_ASSIGNEES_URL) + .auth(alertsReadUser.username, alertsReadUser.password) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ + ids: ['test-id'], + assignees: { + add: ['user1'], + remove: [], + }, + }) + .expect(403); + + expect(body).toEqual( + getMissingAlertsUpdatePrivilegesError({ routeDetails: `POST ${DETECTION_ENGINE_SET_UNIFIED_ALERTS_ASSIGNEES_URL}`, }) ); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_assignees/set_alert_assignees_serverless.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_assignees/set_alert_assignees_serverless.ts index 31fefcb744b07..6cf7436e3c84e 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_assignees/set_alert_assignees_serverless.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_assignees/set_alert_assignees_serverless.ts @@ -17,8 +17,8 @@ import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST, } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../../../ftr_provider_context'; -import { noKibanaPrivileges, rulesRead } from '../../utils/auth/roles'; -import { getMissingSecurityKibanaPrivilegesError } from '../../utils/privileges_errors'; +import { noKibanaPrivileges, alertsAll, alertsUpdateLegacy } from '../../utils/auth/roles'; +import { getMissingAlertsUpdatePrivilegesError } from '../../utils/privileges_errors'; export default ({ getService }: FtrProviderContext) => { const utils = getService('securitySolutionUtils'); @@ -61,8 +61,8 @@ export default ({ getService }: FtrProviderContext) => { describe('RBAC', () => { describe('Kibana privileges', () => { - it('should update assignees with rules read privileges', async () => { - const testAgent = await utils.createSuperTestWithCustomRole(rulesRead); + it('should update assignees with alerts all privileges', async () => { + const testAgent = await utils.createSuperTestWithCustomRole(alertsAll); const { body } = await testAgent .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_ASSIGNEES_URL) @@ -81,7 +81,27 @@ export default ({ getService }: FtrProviderContext) => { expect(body).toHaveProperty('updated'); }); - it('should not update assignees without rules read privileges', async () => { + it('should update assignees with legacy alerts update privileges', async () => { + const testAgent = await utils.createSuperTestWithCustomRole(alertsUpdateLegacy); + + const { body } = await testAgent + .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ + ids: ['test-id'], + assignees: { + add: ['user1'], + remove: [], + }, + }) + .expect(200); + + expect(body).toHaveProperty('updated'); + }); + + it('should not update assignees without alerts read privileges', async () => { const testAgent = await utils.createSuperTestWithCustomRole(noKibanaPrivileges); const { body } = await testAgent @@ -99,7 +119,7 @@ export default ({ getService }: FtrProviderContext) => { .expect(403); expect(body).toEqual( - getMissingSecurityKibanaPrivilegesError({ + getMissingAlertsUpdatePrivilegesError({ routeDetails: `POST ${DETECTION_ENGINE_SET_UNIFIED_ALERTS_ASSIGNEES_URL}`, }) ); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_tags/set_alert_tags_ess.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_tags/set_alert_tags_ess.ts index 9c9a3678a1290..949d8954bd477 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_tags/set_alert_tags_ess.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_tags/set_alert_tags_ess.ts @@ -16,8 +16,13 @@ import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST, } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../../../ftr_provider_context'; -import { noKibanaPrivileges, rulesReadUser } from '../../utils/auth/users'; -import { getMissingSecurityKibanaPrivilegesError } from '../../utils/privileges_errors'; +import { + noKibanaPrivileges, + alertsReadUser, + alertsAllUser, + alertsUpdateLegacyUser, +} from '../../utils/auth/users'; +import { getMissingAlertsUpdatePrivilegesError } from '../../utils/privileges_errors'; export default ({ getService }: FtrProviderContext) => { const supertestWithoutAuth = getService('supertestWithoutAuth'); @@ -25,10 +30,10 @@ export default ({ getService }: FtrProviderContext) => { describe('@ess Set Alert Tags - ESS', () => { describe('RBAC', () => { describe('Kibana privileges', () => { - it('should update tags with rules read privileges', async () => { + it('should update tags with alerts all privileges', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_TAGS_URL) - .auth(rulesReadUser.username, rulesReadUser.password) + .auth(alertsAllUser.username, alertsAllUser.password) .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') @@ -44,7 +49,26 @@ export default ({ getService }: FtrProviderContext) => { expect(body).toHaveProperty('updated'); }); - it('should not update tags without rules read privileges', async () => { + it('should update tags with legacy alerts update privileges', async () => { + const { body } = await supertestWithoutAuth + .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_TAGS_URL) + .auth(alertsUpdateLegacyUser.username, alertsUpdateLegacyUser.password) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ + ids: ['test-id'], + tags: { + tags_to_add: ['test-tag'], + tags_to_remove: [], + }, + }) + .expect(200); + + expect(body).toHaveProperty('updated'); + }); + + it('should not update tags without alerts read privileges', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_TAGS_URL) .auth(noKibanaPrivileges.username, noKibanaPrivileges.password) @@ -61,7 +85,30 @@ export default ({ getService }: FtrProviderContext) => { .expect(403); expect(body).toEqual( - getMissingSecurityKibanaPrivilegesError({ + getMissingAlertsUpdatePrivilegesError({ + routeDetails: `POST ${DETECTION_ENGINE_SET_UNIFIED_ALERTS_TAGS_URL}`, + }) + ); + }); + + it('should not update tags with alerts read privileges', async () => { + const { body } = await supertestWithoutAuth + .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_TAGS_URL) + .auth(alertsReadUser.username, alertsReadUser.password) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ + ids: ['test-id'], + tags: { + tags_to_add: ['test-tag'], + tags_to_remove: [], + }, + }) + .expect(403); + + expect(body).toEqual( + getMissingAlertsUpdatePrivilegesError({ routeDetails: `POST ${DETECTION_ENGINE_SET_UNIFIED_ALERTS_TAGS_URL}`, }) ); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_tags/set_alert_tags_serverless.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_tags/set_alert_tags_serverless.ts index d3e65dc05cd43..a22f3d773e665 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_tags/set_alert_tags_serverless.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_alert_tags/set_alert_tags_serverless.ts @@ -17,8 +17,8 @@ import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST, } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../../../ftr_provider_context'; -import { noKibanaPrivileges, rulesRead } from '../../utils/auth/roles'; -import { getMissingSecurityKibanaPrivilegesError } from '../../utils/privileges_errors'; +import { noKibanaPrivileges, alertsAll, alertsUpdateLegacy } from '../../utils/auth/roles'; +import { getMissingAlertsUpdatePrivilegesError } from '../../utils/privileges_errors'; export default ({ getService }: FtrProviderContext) => { const utils = getService('securitySolutionUtils'); @@ -61,8 +61,8 @@ export default ({ getService }: FtrProviderContext) => { describe('RBAC', () => { describe('Kibana privileges', () => { - it('should update tags with rules read privileges', async () => { - const testAgent = await utils.createSuperTestWithCustomRole(rulesRead); + it('should update tags with alerts all privileges', async () => { + const testAgent = await utils.createSuperTestWithCustomRole(alertsAll); const { body } = await testAgent .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_TAGS_URL) @@ -81,7 +81,27 @@ export default ({ getService }: FtrProviderContext) => { expect(body).toHaveProperty('updated'); }); - it('should not update tags without rules read privileges', async () => { + it('should update tags with legacy alerts update privileges', async () => { + const testAgent = await utils.createSuperTestWithCustomRole(alertsUpdateLegacy); + + const { body } = await testAgent + .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_TAGS_URL) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ + ids: ['test-id'], + tags: { + tags_to_add: ['test-tag'], + tags_to_remove: [], + }, + }) + .expect(200); + + expect(body).toHaveProperty('updated'); + }); + + it('should not update tags without alerts read privileges', async () => { const testAgent = await utils.createSuperTestWithCustomRole(noKibanaPrivileges); const { body } = await testAgent @@ -99,7 +119,7 @@ export default ({ getService }: FtrProviderContext) => { .expect(403); expect(body).toEqual( - getMissingSecurityKibanaPrivilegesError({ + getMissingAlertsUpdatePrivilegesError({ routeDetails: `POST ${DETECTION_ENGINE_SET_UNIFIED_ALERTS_TAGS_URL}`, }) ); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_workflow_status/set_workflow_status_ess.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_workflow_status/set_workflow_status_ess.ts index c258544dceb3c..c25308ae2e552 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_workflow_status/set_workflow_status_ess.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_workflow_status/set_workflow_status_ess.ts @@ -16,8 +16,13 @@ import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST, } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../../../ftr_provider_context'; -import { noKibanaPrivileges, rulesReadUser } from '../../utils/auth/users'; -import { getMissingSecurityKibanaPrivilegesError } from '../../utils/privileges_errors'; +import { + noKibanaPrivileges, + alertsReadUser, + alertsAllUser, + alertsUpdateLegacyUser, +} from '../../utils/auth/users'; +import { getMissingAlertsUpdatePrivilegesError } from '../../utils/privileges_errors'; export default ({ getService }: FtrProviderContext) => { const supertestWithoutAuth = getService('supertestWithoutAuth'); @@ -25,10 +30,26 @@ export default ({ getService }: FtrProviderContext) => { describe('@ess Set Workflow Status - ESS', () => { describe('RBAC', () => { describe('Kibana privileges', () => { - it('should update alerts with rules read privileges', async () => { + it('should update alerts with alerts all privileges', async () => { + const { body } = await supertestWithoutAuth + .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_WORKFLOW_STATUS_URL) + .auth(alertsAllUser.username, alertsAllUser.password) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ + signal_ids: ['test-id'], + status: 'closed', + }) + .expect(200); + + expect(body).toHaveProperty('updated'); + }); + + it('should update alerts with legacy alerts update privileges', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_WORKFLOW_STATUS_URL) - .auth(rulesReadUser.username, rulesReadUser.password) + .auth(alertsUpdateLegacyUser.username, alertsUpdateLegacyUser.password) .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') @@ -41,7 +62,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body).toHaveProperty('updated'); }); - it('should not update alerts without rules read privileges', async () => { + it('should not update alerts without privileges', async () => { const { body } = await supertestWithoutAuth .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_WORKFLOW_STATUS_URL) .auth(noKibanaPrivileges.username, noKibanaPrivileges.password) @@ -55,7 +76,27 @@ export default ({ getService }: FtrProviderContext) => { .expect(403); expect(body).toEqual( - getMissingSecurityKibanaPrivilegesError({ + getMissingAlertsUpdatePrivilegesError({ + routeDetails: `POST ${DETECTION_ENGINE_SET_UNIFIED_ALERTS_WORKFLOW_STATUS_URL}`, + }) + ); + }); + + it('should not update alerts with alerts read privileges', async () => { + const { body } = await supertestWithoutAuth + .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_WORKFLOW_STATUS_URL) + .auth(alertsReadUser.username, alertsReadUser.password) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ + signal_ids: ['test-id'], + status: 'closed', + }) + .expect(403); + + expect(body).toEqual( + getMissingAlertsUpdatePrivilegesError({ routeDetails: `POST ${DETECTION_ENGINE_SET_UNIFIED_ALERTS_WORKFLOW_STATUS_URL}`, }) ); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_workflow_status/set_workflow_status_serverless.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_workflow_status/set_workflow_status_serverless.ts index 36a5f84fe7437..bb1b33fb79d82 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_workflow_status/set_workflow_status_serverless.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/basic_license_essentials_tier/set_workflow_status/set_workflow_status_serverless.ts @@ -17,8 +17,8 @@ import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST, } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../../../ftr_provider_context'; -import { noKibanaPrivileges, rulesRead } from '../../utils/auth/roles'; -import { getMissingSecurityKibanaPrivilegesError } from '../../utils/privileges_errors'; +import { noKibanaPrivileges, alertsAll, alertsUpdateLegacy } from '../../utils/auth/roles'; +import { getMissingAlertsUpdatePrivilegesError } from '../../utils/privileges_errors'; export default ({ getService }: FtrProviderContext) => { const utils = getService('securitySolutionUtils'); @@ -58,8 +58,25 @@ export default ({ getService }: FtrProviderContext) => { describe('RBAC', () => { describe('Kibana privileges', () => { - it('should update alerts with rules read privileges', async () => { - const testAgent = await utils.createSuperTestWithCustomRole(rulesRead); + it('should update alerts with alerts all privileges', async () => { + const testAgent = await utils.createSuperTestWithCustomRole(alertsAll); + + const { body } = await testAgent + .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_WORKFLOW_STATUS_URL) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ + signal_ids: ['test-id'], + status: 'closed', + }) + .expect(200); + + expect(body).toHaveProperty('updated'); + }); + + it('should update alerts with legacy alerts update privileges', async () => { + const testAgent = await utils.createSuperTestWithCustomRole(alertsUpdateLegacy); const { body } = await testAgent .post(DETECTION_ENGINE_SET_UNIFIED_ALERTS_WORKFLOW_STATUS_URL) @@ -90,7 +107,7 @@ export default ({ getService }: FtrProviderContext) => { .expect(403); expect(body).toEqual( - getMissingSecurityKibanaPrivilegesError({ + getMissingAlertsUpdatePrivilegesError({ routeDetails: `POST ${DETECTION_ENGINE_SET_UNIFIED_ALERTS_WORKFLOW_STATUS_URL}`, }) ); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/auth/roles.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/auth/roles.ts index 15f1590f4ae8d..92e2156a1ebf1 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/auth/roles.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/auth/roles.ts @@ -6,9 +6,10 @@ */ import { + ALERTS_FEATURE_ID, ATTACK_DISCOVERY_FEATURE_ID, - RULES_FEATURE_ID, } from '@kbn/security-solution-plugin/common/constants'; +import { RULES_FEATURE_ID_V2 } from '@kbn/security-solution-features/constants'; import type { Role } from './types'; export const noKibanaPrivileges: Role = { @@ -28,8 +29,8 @@ export const noKibanaPrivileges: Role = { }, }; -export const rulesRead: Role = { - name: 'rules_read_all_spaces', +export const alertsRead: Role = { + name: 'alerts_read_all_spaces', privileges: { elasticsearch: { indices: [ @@ -45,7 +46,7 @@ export const rulesRead: Role = { kibana: [ { feature: { - [RULES_FEATURE_ID]: ['read'], + [ALERTS_FEATURE_ID]: ['read'], }, spaces: ['*'], }, @@ -53,8 +54,48 @@ export const rulesRead: Role = { }, }; -export const rulesReadNoIndices: Role = { - name: 'rules_read_all_spaces_no_indices', +const alertsIndicesPrivilege = { + names: ['.alerts-security.alerts-default', '.alerts-security.attack.discovery.alerts-default'], + privileges: ['all'], +}; + +export const alertsAll: Role = { + name: 'alerts_all_all_spaces', + privileges: { + elasticsearch: { + indices: [alertsIndicesPrivilege], + }, + kibana: [ + { + feature: { + [ALERTS_FEATURE_ID]: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +/** Role with Rules "read" which grants the legacy alerts update (deprecated) privilege */ +export const alertsUpdateLegacy: Role = { + name: 'alerts_update_legacy_all_spaces', + privileges: { + elasticsearch: { + indices: [alertsIndicesPrivilege], + }, + kibana: [ + { + feature: { + [RULES_FEATURE_ID_V2]: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const alertsReadNoIndices: Role = { + name: 'alerts_read_all_spaces_no_indices', privileges: { elasticsearch: { indices: [], @@ -62,7 +103,7 @@ export const rulesReadNoIndices: Role = { kibana: [ { feature: { - [RULES_FEATURE_ID]: ['read'], + [ALERTS_FEATURE_ID]: ['read'], }, spaces: ['*'], }, @@ -70,8 +111,8 @@ export const rulesReadNoIndices: Role = { }, }; -export const rulesReadNoDetectionIndices: Role = { - name: 'rules_read_all_spaces_no_detection_indices', +export const alertsReadNoDetectionIndices: Role = { + name: 'alerts_read_all_spaces_no_detection_indices', privileges: { elasticsearch: { indices: [ @@ -84,7 +125,7 @@ export const rulesReadNoDetectionIndices: Role = { kibana: [ { feature: { - [RULES_FEATURE_ID]: ['read'], + [ALERTS_FEATURE_ID]: ['read'], }, spaces: ['*'], }, @@ -92,8 +133,8 @@ export const rulesReadNoDetectionIndices: Role = { }, }; -export const rulesReadNoAttackIndices: Role = { - name: 'rules_read_all_spaces_no_attack_indices', +export const alertsReadNoAttackIndices: Role = { + name: 'alerts_read_all_spaces_no_attack_indices', privileges: { elasticsearch: { indices: [ @@ -106,7 +147,7 @@ export const rulesReadNoAttackIndices: Role = { kibana: [ { feature: { - [RULES_FEATURE_ID]: ['read'], + [ALERTS_FEATURE_ID]: ['read'], }, spaces: ['*'], }, @@ -139,8 +180,8 @@ export const attackDiscoveryOnlyAll: Role = { }, }; -export const rulesReadAndAttackDiscoveryAll: Role = { - name: 'rules_read_and_attack_discovery_all_spaces', +export const alertsReadAndAttackDiscoveryAll: Role = { + name: 'alerts_read_and_attack_discovery_all_spaces', privileges: { elasticsearch: { indices: [ @@ -157,7 +198,7 @@ export const rulesReadAndAttackDiscoveryAll: Role = { { feature: { [ATTACK_DISCOVERY_FEATURE_ID]: ['all'], - [RULES_FEATURE_ID]: ['read'], + [ALERTS_FEATURE_ID]: ['read'], }, spaces: ['*'], }, @@ -167,10 +208,12 @@ export const rulesReadAndAttackDiscoveryAll: Role = { export const allRoles = [ noKibanaPrivileges, - rulesRead, - rulesReadNoIndices, - rulesReadNoDetectionIndices, - rulesReadNoAttackIndices, + alertsRead, + alertsAll, + alertsUpdateLegacy, + alertsReadNoIndices, + alertsReadNoDetectionIndices, + alertsReadNoAttackIndices, attackDiscoveryOnlyAll, - rulesReadAndAttackDiscoveryAll, + alertsReadAndAttackDiscoveryAll, ]; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/auth/users.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/auth/users.ts index 43b79b0946760..03d0643f7ea60 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/auth/users.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/auth/users.ts @@ -6,12 +6,14 @@ */ import { - rulesRead, - rulesReadNoIndices, - rulesReadNoDetectionIndices, - rulesReadNoAttackIndices, + alertsRead, + alertsAll, + alertsUpdateLegacy, + alertsReadNoIndices, + alertsReadNoDetectionIndices, + alertsReadNoAttackIndices, attackDiscoveryOnlyAll, - rulesReadAndAttackDiscoveryAll, + alertsReadAndAttackDiscoveryAll, noKibanaPrivileges as noKibanaPrivilegesRole, } from './roles'; import type { User } from './types'; @@ -28,28 +30,40 @@ export const noKibanaPrivileges: User = { roles: [noKibanaPrivilegesRole.name], }; -export const rulesReadUser: User = { - username: 'rules_read_all_spaces', - password: 'rules_read_all_spaces', - roles: [rulesRead.name], +export const alertsReadUser: User = { + username: 'alerts_read_all_spaces', + password: 'alerts_read_all_spaces', + roles: [alertsRead.name], }; -export const rulesReadNoIndicesUser: User = { - username: 'rules_read_all_spaces_no_indices', - password: 'rules_read_all_spaces_no_indices', - roles: [rulesReadNoIndices.name], +export const alertsAllUser: User = { + username: 'alerts_all_all_spaces', + password: 'alerts_all_all_spaces', + roles: [alertsAll.name], }; -export const rulesReadNoDetectionIndicesUser: User = { - username: 'rules_read_all_spaces_no_detection_indices', - password: 'rules_read_all_spaces_no_detection_indices', - roles: [rulesReadNoDetectionIndices.name], +export const alertsUpdateLegacyUser: User = { + username: 'alerts_update_legacy_all_spaces', + password: 'alerts_update_legacy_all_spaces', + roles: [alertsUpdateLegacy.name], }; -export const rulesReadNoAttackIndicesUser: User = { - username: 'rules_read_all_spaces_no_attack_indices', - password: 'rules_read_all_spaces_no_attack_indices', - roles: [rulesReadNoAttackIndices.name], +export const alertsReadNoIndicesUser: User = { + username: 'alerts_read_all_spaces_no_indices', + password: 'alerts_read_all_spaces_no_indices', + roles: [alertsReadNoIndices.name], +}; + +export const alertsReadNoDetectionIndicesUser: User = { + username: 'alerts_read_all_spaces_no_detection_indices', + password: 'alerts_read_all_spaces_no_detection_indices', + roles: [alertsReadNoDetectionIndices.name], +}; + +export const alertsReadNoAttackIndicesUser: User = { + username: 'alerts_read_all_spaces_no_attack_indices', + password: 'alerts_read_all_spaces_no_attack_indices', + roles: [alertsReadNoAttackIndices.name], }; export const attackDiscoveryOnly: User = { @@ -58,19 +72,21 @@ export const attackDiscoveryOnly: User = { roles: [attackDiscoveryOnlyAll.name], }; -export const rulesReadAndAttackDiscoveryAllUser: User = { - username: 'rules_read_and_attack_discovery_all_spaces', - password: 'rules_read_and_attack_discovery_all_spaces', - roles: [rulesReadAndAttackDiscoveryAll.name], +export const alertsReadAndAttackDiscoveryAllUser: User = { + username: 'alerts_read_and_attack_discovery_all_spaces', + password: 'alerts_read_and_attack_discovery_all_spaces', + roles: [alertsReadAndAttackDiscoveryAll.name], }; export const allUsers = [ superUser, noKibanaPrivileges, - rulesReadUser, - rulesReadNoIndicesUser, - rulesReadNoDetectionIndicesUser, - rulesReadNoAttackIndicesUser, + alertsReadUser, + alertsAllUser, + alertsUpdateLegacyUser, + alertsReadNoIndicesUser, + alertsReadNoDetectionIndicesUser, + alertsReadNoAttackIndicesUser, attackDiscoveryOnly, - rulesReadAndAttackDiscoveryAllUser, + alertsReadAndAttackDiscoveryAllUser, ]; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/privileges_errors.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/privileges_errors.ts index 84873c7c436e0..5627a9861afff 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/privileges_errors.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/unified_alerts/utils/privileges_errors.ts @@ -5,14 +5,22 @@ * 2.0. */ -export const getMissingSecurityKibanaPrivilegesError = ({ +export const getMissingAlertsReadPrivilegesError = ({ routeDetails }: { routeDetails: string }) => { + return { + error: 'Forbidden', + message: `API [${routeDetails}] is unauthorized for user, this action is granted by the Kibana privileges [alerts-read]`, + statusCode: 403, + }; +}; + +export const getMissingAlertsUpdatePrivilegesError = ({ routeDetails, }: { routeDetails: string; }) => { return { error: 'Forbidden', - message: `API [${routeDetails}] is unauthorized for user, this action is granted by the Kibana privileges [alerts-read]`, + message: `API [${routeDetails}] is unauthorized for user, this action is granted by the Kibana privileges [alerts-all,alerts-signal-update-deprecated-privilege]`, statusCode: 403, }; }; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_enable_disable.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_enable_disable.ts index a470552d2e58d..c2aea89b70a94 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_enable_disable.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_enable_disable.ts @@ -8,13 +8,14 @@ import expect from 'expect'; import { BulkActionTypeEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management'; import { createRule, deleteAllRules } from '@kbn/detections-response-ftr-services'; -import { getCustomQueryRuleParams, fetchRule } from '../../../utils'; +import { getCustomQueryRuleParams, fetchRule, rulesAllV3OnlyRole } from '../../../utils'; import type { FtrProviderContext } from '../../../../../ftr_provider_context'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const detectionsApi = getService('detectionsApi'); const log = getService('log'); + const utils = getService('securitySolutionUtils'); describe('@ess @serverless @serverlessQA Bulk enable/disable', () => { beforeEach(async () => { @@ -66,5 +67,59 @@ export default ({ getService }: FtrProviderContext): void => { const ruleBody = await fetchRule(supertest, { ruleId }); expect(ruleBody.enabled).toEqual(false); }); + + describe('@skipInServerless as various roles', () => { + beforeEach(async () => { + await utils.createSuperTestWithCustomRole(rulesAllV3OnlyRole); + }); + + afterEach(async () => { + await utils.cleanUpCustomRoles(); + }); + + it('allows enabling/disabling rules with the Rules:All feature', async () => { + const ruleId = 'ruleId'; + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: ruleId, enabled: false }) + ); + + const restrictedApis = detectionsApi.withUser({ username: rulesAllV3OnlyRole.name }); + + const { body: enableBody } = await restrictedApis + .performRulesBulkAction({ + query: {}, + body: { action: BulkActionTypeEnum.enable }, + }) + .expect(200); + + expect(enableBody.attributes.summary).toEqual({ + failed: 0, + skipped: 0, + succeeded: 1, + total: 1, + }); + expect(enableBody.attributes.results.updated[0].enabled).toEqual(true); + + const { body: disableBody } = await restrictedApis + .performRulesBulkAction({ + query: {}, + body: { action: BulkActionTypeEnum.disable }, + }) + .expect(200); + + expect(disableBody.attributes.summary).toEqual({ + failed: 0, + skipped: 0, + succeeded: 1, + total: 1, + }); + expect(disableBody.attributes.results.updated[0].enabled).toEqual(false); + + const ruleBody = await fetchRule(supertest, { ruleId }); + expect(ruleBody.enabled).toEqual(false); + }); + }); }); }; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/auth/index.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/auth/index.ts new file mode 100644 index 0000000000000..00b3bd26cebf1 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/auth/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './roles'; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/auth/roles.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/auth/roles.ts new file mode 100644 index 0000000000000..1ab38ddc81ede --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/auth/roles.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RULES_FEATURE_ID_V3 } from '@kbn/security-solution-features/constants'; +import type { CustomRole } from '../../../../config/services/types'; + +/** Role with only Rules V3 ALL (no Alerts or other SIEM features). */ +export const rulesAllV3OnlyRole: CustomRole = { + name: 'rules_all_v3_only', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + [RULES_FEATURE_ID_V3]: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts index 76f83a7ba3547..816a043622a4e 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + +export * from './auth'; export * from './rules'; export * from './exception_list_and_item'; export * from './alerts'; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/privileges/utils/roles.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/privileges/utils/roles.ts index d168adf9b79a7..183561b3932aa 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/privileges/utils/roles.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/privileges/utils/roles.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { SECURITY_FEATURE_ID } from '@kbn/security-solution-plugin/common/constants'; +import { + SECURITY_FEATURE_ID, + ALERTS_FEATURE_ID, +} from '@kbn/security-solution-plugin/common/constants'; import type { Role } from '../../../utils/auth/types'; export const noIndexPrivileges: Role = { @@ -18,6 +21,7 @@ export const noIndexPrivileges: Role = { { feature: { [SECURITY_FEATURE_ID]: ['all'], + [ALERTS_FEATURE_ID]: ['read'], securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], }, @@ -42,6 +46,7 @@ export const noAdhocIndexPrivileges: Role = { { feature: { [SECURITY_FEATURE_ID]: ['all'], + [ALERTS_FEATURE_ID]: ['read'], securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], }, @@ -66,6 +71,7 @@ export const noAttacksIndexPrivileges: Role = { { feature: { [SECURITY_FEATURE_ID]: ['all'], + [ALERTS_FEATURE_ID]: ['read'], securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], }, @@ -90,6 +96,7 @@ export const allIndexPrivileges: Role = { { feature: { [SECURITY_FEATURE_ID]: ['all'], + [ALERTS_FEATURE_ID]: ['read'], securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], }, diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/schedules/utils/helpers.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/schedules/utils/helpers.ts index 5866864090846..6462998a3ea71 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/schedules/utils/helpers.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/schedules/utils/helpers.ts @@ -122,7 +122,7 @@ export const getMissingAssistantKibanaPrivilegesError = ({ }) => { return { error: 'Forbidden', - message: `API [${routeDetails}] is unauthorized for user, this action is granted by the Kibana privileges [securitySolution-attackDiscoveryAll]`, + message: `API [${routeDetails}] is unauthorized for user, this action is granted by the Kibana privileges [securitySolution-attackDiscoveryAll,alerts-read]`, statusCode: 403, }; }; @@ -147,8 +147,8 @@ export const getMissingAssistantAndScheduleKibanaPrivilegesError = ({ // The PUT UPDATE route has privileges listed in reverse order compared to the CREATE/ENABLE/DISABLE routes const isUpdateRoute = routeDetails.startsWith('PUT '); const privileges = isUpdateRoute - ? '[securitySolution-updateAttackDiscoverySchedule,securitySolution-attackDiscoveryAll]' // PUT - : '[securitySolution-attackDiscoveryAll,securitySolution-updateAttackDiscoverySchedule]'; // CREATE/ENABLE/DISABLE + ? '[securitySolution-updateAttackDiscoverySchedule,securitySolution-attackDiscoveryAll,alerts-read]' // PUT + : '[securitySolution-attackDiscoveryAll,securitySolution-updateAttackDiscoverySchedule,alerts-read]'; // CREATE/ENABLE/DISABLE return { error: 'Forbidden', diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/utils/auth/roles.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/utils/auth/roles.ts index 850ab0da1861b..128307febe171 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/utils/auth/roles.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/utils/auth/roles.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { SECURITY_FEATURE_ID } from '@kbn/security-solution-plugin/common/constants'; +import { + SECURITY_FEATURE_ID, + ALERTS_FEATURE_ID, +} from '@kbn/security-solution-plugin/common/constants'; import type { Role } from './types'; export const noKibanaPrivileges: Role = { @@ -129,6 +132,7 @@ export const securitySolutionOnlyAllSpacesAll: Role = { { feature: { [SECURITY_FEATURE_ID]: ['all'], + [ALERTS_FEATURE_ID]: ['read'], securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], }, @@ -210,6 +214,7 @@ export const securitySolutionOnlyAllSpacesAllAttackDiscoveryMinimalAll: Role = { { feature: { [SECURITY_FEATURE_ID]: ['all'], + [ALERTS_FEATURE_ID]: ['read'], securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['minimal_all'], }, diff --git a/x-pack/solutions/security/test/security_solution_api_integration/tsconfig.json b/x-pack/solutions/security/test/security_solution_api_integration/tsconfig.json index 4f6ef0ab46392..45649ac49e22e 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/tsconfig.json +++ b/x-pack/solutions/security/test/security_solution_api_integration/tsconfig.json @@ -66,6 +66,7 @@ "@kbn/cloud-security-posture-common", "@kbn/detections-response-ftr-services", "@kbn/connector-schemas", + "@kbn/security-solution-features", "@kbn/spaces-utils", ] } diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/legacy_alert_privileges.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/legacy_alert_privileges.cy.ts new file mode 100644 index 0000000000000..0359cc83e8d07 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/legacy_alert_privileges.cy.ts @@ -0,0 +1,300 @@ +/* + * 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 { getCustomQueryRuleParams } from '../../../../objects/rule'; +import { TIMELINE_CONTEXT_MENU_BTN, TAKE_ACTION_POPOVER_BTN } from '../../../../screens/alerts'; +import { + addAlertTagToNAlerts, + closeAlerts, + markAcknowledgedFirstAlert, + openFirstAlert, + selectNumberOfAlerts, + goToClosedAlerts, + goToAcknowledgedAlerts, + waitForAlerts, +} from '../../../../tasks/alerts'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { loginWithUser } from '../../../../tasks/login'; +import { visit } from '../../../../tasks/navigation'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { createUsersAndRoles, deleteUsersAndRoles } from '../../../../tasks/privileges'; +import { assertSuccessToast } from '../../../../screens/common/toast'; +import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; +import { + removeAllAssigneesForFirstAlert, + updateAssigneesForFirstAlert, +} from '../../../../tasks/alert_assignments'; +import { sortUsingDataGridBtn } from '../../../../tasks/table_pagination'; + +// ==================================== +// Role Definitions +// ==================================== + +// Legacy Rules V1 Read Role - has deprecated alert update privileges +const legacyRulesV1Read = { + name: 'legacy_rules_v1_read_role', + privileges: { + elasticsearch: { + indices: [{ names: ['*'], privileges: ['all'] }], + }, + kibana: [ + { + feature: { + securitySolutionRulesV1: ['read'], + savedObjectManagement: ['all'], + indexPatterns: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +// Legacy Security V1 Read Role - has deprecated alert update privileges +const legacySecurityV1Read = { + name: 'legacy_security_v1_read_role', + privileges: { + elasticsearch: { + indices: [{ names: ['*'], privileges: ['all'] }], + }, + kibana: [ + { + feature: { + siemV1: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +// Legacy Security V2 Read Role - has deprecated alert update privileges +const legacySecurityV2Read = { + name: 'legacy_security_v2_read_role', + privileges: { + elasticsearch: { + indices: [{ names: ['*'], privileges: ['all'] }], + }, + kibana: [ + { + feature: { + siemV2: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +// Legacy Rules V2 Read Role - has deprecated alert update privileges +const legacyRulesV2Read = { + name: 'legacy_rules_v2_read_role', + privileges: { + elasticsearch: { + indices: [{ names: ['*'], privileges: ['all'] }], + }, + kibana: [ + { + feature: { + securitySolutionRulesV2: ['read'], + savedObjectManagement: ['all'], + indexPatterns: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +// Alerts V1 Read Only Role - does NOT have deprecated alert update privileges +const alertsV1ReadOnly = { + name: 'alerts_v1_read_only_role', + privileges: { + elasticsearch: { + indices: [{ names: ['*'], privileges: ['all'] }], + }, + kibana: [ + { + feature: { + securitySolutionAlertsV1: ['read'], + indexPatterns: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +// User definitions +const legacyRulesV1ReadUser = { + username: 'legacy_rules_v1_read_user', + password: 'password', + roles: [legacyRulesV1Read.name], +}; + +const legacySecurityV1ReadUser = { + username: 'legacy_security_v1_read_user', + password: 'password', + roles: [legacySecurityV1Read.name], +}; + +const legacySecurityV2ReadUser = { + username: 'legacy_security_v2_read_user', + password: 'password', + roles: [legacySecurityV2Read.name], +}; + +const legacyRulesV2ReadUser = { + username: 'legacy_rules_v2_read_user', + password: 'password', + roles: [legacyRulesV2Read.name], +}; + +const alertsV1ReadOnlyUser = { + username: 'alerts_v1_read_only_user', + password: 'password', + roles: [alertsV1ReadOnly.name], +}; + +const usersToCreate = [ + legacyRulesV1ReadUser, + legacySecurityV1ReadUser, + legacySecurityV2ReadUser, + legacyRulesV2ReadUser, + alertsV1ReadOnlyUser, +]; + +const rolesToCreate = [ + legacyRulesV1Read, + legacySecurityV1Read, + legacySecurityV2Read, + legacyRulesV2Read, + alertsV1ReadOnly, +]; + +// ==================================== +// Test Suite +// ==================================== + +describe('Legacy Alerts Privileges', { tags: ['@ess'] }, () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: 'auditbeat_multiple' }); + deleteAlertsAndRules(); + deleteUsersAndRoles(usersToCreate, rolesToCreate); + createUsersAndRoles(usersToCreate, rolesToCreate); + }); + + after(() => { + cy.task('esArchiverUnload', { archiveName: 'auditbeat_multiple' }); + deleteUsersAndRoles(usersToCreate, rolesToCreate); + }); + + beforeEach(() => { + // Login as admin to create rules and alerts + loginWithUser(legacyRulesV1ReadUser); + deleteAlertsAndRules(); + createRule(getCustomQueryRuleParams()); + }); + + // ==================================== + // Legacy Rules V1 Read - SHOULD be able to manage alerts + // ==================================== + describe('securitySolutionRulesV1.read (legacy)', () => { + beforeEach(() => { + loginWithUser(legacyRulesV1ReadUser); + visit(ALERTS_URL); + waitForAlertsToPopulate(); + }); + + it('should be able to acknowledge alerts', () => { + markAcknowledgedFirstAlert(); + assertSuccessToast('Successfully marked 1 alert as acknowledged.'); + goToAcknowledgedAlerts(); + waitForAlertsToPopulate(1); + }); + + it('should be able to close alerts', () => { + selectNumberOfAlerts(3); + closeAlerts(); + assertSuccessToast('Successfully closed 3 alerts.'); + }); + + it('should be able to open closed alerts', () => { + // First close an alert + selectNumberOfAlerts(1); + closeAlerts(); + goToClosedAlerts(); + waitForAlerts(); + // Then open it + openFirstAlert(); + }); + + it('should be able to assign alerts', () => { + sortUsingDataGridBtn('Assignees'); + const assignees = [legacyRulesV1ReadUser.username]; + updateAssigneesForFirstAlert(assignees); + removeAllAssigneesForFirstAlert(); + }); + + it('should be able to tag alerts', () => { + addAlertTagToNAlerts(5); + }); + }); + + // ==================================== + // Legacy Rules V2 Read - SHOULD be able to manage alerts + // ==================================== + describe('securitySolutionRulesV2.read (legacy)', () => { + beforeEach(() => { + loginWithUser(legacyRulesV2ReadUser); + visit(ALERTS_URL); + waitForAlertsToPopulate(); + }); + + it('should be able to acknowledge alerts', () => { + markAcknowledgedFirstAlert(); + }); + + it('should be able to close alerts', () => { + selectNumberOfAlerts(2); + closeAlerts(); + assertSuccessToast('Successfully closed 2 alerts.'); + }); + + it('should be able to assign alerts', () => { + sortUsingDataGridBtn('Assignees'); + const assignees = [legacyRulesV2ReadUser.username]; + updateAssigneesForFirstAlert(assignees); + removeAllAssigneesForFirstAlert(); + }); + + it('should be able to tag alerts', () => { + addAlertTagToNAlerts(3); + }); + }); + + // ==================================== + // Alerts V1 Read Only - should NOT be able to manage alerts + // ==================================== + describe('securitySolutionAlertsV1.read (new - no management)', () => { + beforeEach(() => { + loginWithUser(alertsV1ReadOnlyUser); + visit(ALERTS_URL); + waitForAlertsToPopulate(); + }); + + it('should NOT be able to manage alerts via the context menu', () => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).should('be.disabled'); + }); + + it('should NOT be able to manage alerts via bulk actions', () => { + selectNumberOfAlerts(2); + cy.get(TAKE_ACTION_POPOVER_BTN).should('not.exist'); + }); + }); +}); diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/privileges.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/privileges.cy.ts index 3b2bd47dbdaca..f5a557a88a429 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/privileges.cy.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/privileges.cy.ts @@ -60,7 +60,7 @@ import { UNSNOOZED_BADGE } from '../../../../screens/rule_snoozing'; const usersToCreate = [rulesAllUser, rulesReadUser, rulesNoneUser]; const rolesToCreate = [rulesAll, rulesRead, rulesNone]; -// As part of the rules RBAC effort, we have created these tests with roles that only have the new rules feature 'securitySolutionVX' enabled in order to test +// As part of the rules RBAC effort, we have created these tests with roles that only have the new rules feature 'securitySolutionRulesVX' enabled in order to test // the features that said roles should have access to. Notice that the roles created are very minimal and only contain the new rules feature. describe('Rules table - privileges', { tags: ['@ess'] }, () => { diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rules_table/privileges.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rules_table/privileges.cy.ts index 5ec260e898b08..e9b2448ebf8e5 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rules_table/privileges.cy.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rules_table/privileges.cy.ts @@ -47,7 +47,7 @@ import { assertSuccessToast } from '../../../../screens/common/toast'; const usersToCreate = [rulesAllUser, rulesReadUser, rulesNoneUser]; const rolesToCreate = [rulesAll, rulesRead, rulesNone]; -// As part of the rules RBAC effort, we have created these tests with roles that only have the new rules feature 'securitySolutionVX' enabled in order to test +// As part of the rules RBAC effort, we have created these tests with roles that only have the new rules feature 'securitySolutionRulesVX' enabled in order to test // the features that said roles should have access to. Notice that the roles created are very minimal and only contain the new rules feature. describe('Rules table - privileges', { tags: ['@ess'] }, () => { diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/alerts.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/alerts.ts index 3de026d7332a5..662ec94bbad3c 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/alerts.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/alerts.ts @@ -38,8 +38,6 @@ export const ALERTS_COUNT = '[data-test-subj="toolbar-alerts-count"]'; export const CLOSE_ALERT_BTN = '[data-test-subj="alert-close-context-menu-item"]'; -export const CLOSE_SELECTED_ALERTS_BTN = '[data-test-subj="alert-close-context-menu-item"]'; - export const CLOSED_ALERTS_FILTER_BTN = '[data-test-subj="closedAlerts"]'; export const EMPTY_ALERT_TABLE = '[data-test-subj="alertsTableEmptyState"]'; @@ -54,9 +52,13 @@ export const TAKE_ACTION_MENU = '[data-test-subj="takeActionPanelMenu"]'; export const CLOSE_FLYOUT = '[data-test-subj="euiFlyoutCloseButton"]'; -export const MARK_ALERT_ACKNOWLEDGED_BTN = '[data-test-subj="acknowledged-alert-status"]'; - +/** + * Selectors for bulk alert actions popover items + */ export const OPEN_ALERT_BTN = '[data-test-subj="open-alert-status"]'; +export const CLOSE_SELECTED_ALERTS_BTN = '[data-test-subj="alert-close-context-menu-item"]'; +export const MARK_ALERT_ACKNOWLEDGED_BTN = '[data-test-subj="acknowledged-alert-status"]'; +export const ALERT_TAGGING_CONTEXT_MENU_ITEM = '[data-test-subj="alert-tags-context-menu-item"]'; export const OPENED_ALERTS_FILTER_BTN = '[data-test-subj="openAlerts"]'; @@ -170,8 +172,6 @@ export const SELECT_TREEMAP = getDataTestSubjectSelector('chart-select-treemap') export const ALERT_TREEMAP = getDataTestSubjectSelector('treemapPanel'); -export const ALERT_TAGGING_CONTEXT_MENU_ITEM = '[data-test-subj="alert-tags-context-menu-item"]'; - export const ALERT_TAGGING_CONTEXT_MENU = '[data-test-subj="alert-tags-selectable-menu"]'; export const ALERT_TAGGING_UPDATE_BUTTON = '[data-test-subj="alert-tags-update-button"]'; diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/alerts.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/alerts.ts index 1aa0d363d4c21..bf5849bb54d44 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/alerts.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/alerts.ts @@ -167,6 +167,11 @@ export const expandFirstAlert = () => { cy.get(EXPAND_ALERT_BTN).first().trigger('click'); }; +export const expandBulkActions = () => { + cy.contains(SELECTED_ALERTS, /Selected \d+ alerts/); + cy.get(TAKE_ACTION_POPOVER_BTN).should('be.visible').click(); +}; + export const hideMessageTooltip = () => { cy.get('body').then(($body) => { if ($body.find(TOOLTIP).length > 0) { @@ -350,8 +355,8 @@ export const openAlertsFieldBrowser = () => { }; export const selectNumberOfAlerts = (numberOfAlerts: number) => { + waitForAlerts(); for (let i = 0; i < numberOfAlerts; i++) { - waitForAlerts(); cy.get(ALERT_CHECKBOX).eq(i).as('checkbox').check(); cy.get('@checkbox').should('be.checked'); } diff --git a/x-pack/solutions/security/test/serverless/api_integration/test_suites/platform_security/authorization.ts b/x-pack/solutions/security/test/serverless/api_integration/test_suites/platform_security/authorization.ts index e1319830bc1b7..9a7f0d5b859c8 100644 --- a/x-pack/solutions/security/test/serverless/api_integration/test_suites/platform_security/authorization.ts +++ b/x-pack/solutions/security/test/serverless/api_integration/test_suites/platform_security/authorization.ts @@ -1210,11 +1210,15 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/showEndpointExceptions", "ui:siemV5/crudEndpointExceptions", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/edit_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", - "ui:securitySolutionRulesV2/editExceptions", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/edit_rules", + "ui:securitySolutionRulesV3/detections", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/editExceptions", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/edit_alerts", + "ui:securitySolutionAlertsV1/detections", ], "blocklist_all": Array [ "login:", @@ -2342,11 +2346,15 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/investigation-guide-interactions", "ui:siemV5/threat-intelligence", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/edit_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", - "ui:securitySolutionRulesV2/editExceptions", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/edit_rules", + "ui:securitySolutionRulesV3/detections", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/editExceptions", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/edit_alerts", + "ui:securitySolutionAlertsV1/detections", ], "minimal_read": Array [ "login:", @@ -2354,6 +2362,7 @@ if (sourceFilePath === 'authorization.ts') { "api:lists-read", "api:rules-read", "api:alerts-read", + "api:alerts-signal-update-deprecated-privilege", "api:exceptions-read", "api:users-read", "api:initialize-security-solution", @@ -2525,6 +2534,7 @@ if (sourceFilePath === 'authorization.ts') { "saved_object:cloud/open_point_in_time", "saved_object:cloud/close_point_in_time", "ui:siem/show", + "ui:siem/edit_alerts-update-deprecated-privilege", "ui:siem/entity-analytics", "ui:siem/detections", "ui:siem/investigation-guide", @@ -2788,9 +2798,12 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/investigation-guide-interactions", "ui:siemV5/threat-intelligence", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/detections", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/detections", ], "policy_management_all": Array [ "login:", @@ -2836,6 +2849,7 @@ if (sourceFilePath === 'authorization.ts') { "api:lists-read", "api:rules-read", "api:alerts-read", + "api:alerts-signal-update-deprecated-privilege", "api:exceptions-read", "api:users-read", "api:initialize-security-solution", @@ -3009,6 +3023,7 @@ if (sourceFilePath === 'authorization.ts') { "saved_object:cloud/open_point_in_time", "saved_object:cloud/close_point_in_time", "ui:siem/show", + "ui:siem/edit_alerts-update-deprecated-privilege", "ui:siem/entity-analytics", "ui:siem/detections", "ui:siem/investigation-guide", @@ -3274,9 +3289,12 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/threat-intelligence", "ui:siemV5/showEndpointExceptions", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/detections", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/detections", ], "scan_operations_all": Array [ "login:", @@ -4222,11 +4240,15 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/showEndpointExceptions", "ui:siemV5/crudEndpointExceptions", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/edit_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", - "ui:securitySolutionRulesV2/editExceptions", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/edit_rules", + "ui:securitySolutionRulesV3/detections", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/editExceptions", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/edit_alerts", + "ui:securitySolutionAlertsV1/detections", ], "blocklist_all": Array [ "login:", @@ -5298,11 +5320,15 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/investigation-guide-interactions", "ui:siemV5/threat-intelligence", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/edit_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", - "ui:securitySolutionRulesV2/editExceptions", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/edit_rules", + "ui:securitySolutionRulesV3/detections", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/editExceptions", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/edit_alerts", + "ui:securitySolutionAlertsV1/detections", ], "minimal_read": Array [ "login:", @@ -5311,6 +5337,7 @@ if (sourceFilePath === 'authorization.ts') { "api:lists-read", "api:rules-read", "api:alerts-read", + "api:alerts-signal-update-deprecated-privilege", "api:exceptions-read", "api:users-read", "api:initialize-security-solution", @@ -5464,6 +5491,7 @@ if (sourceFilePath === 'authorization.ts') { "saved_object:cloud/open_point_in_time", "saved_object:cloud/close_point_in_time", "ui:siemV2/show", + "ui:siemV2/edit_alerts-update-deprecated-privilege", "ui:siemV2/entity-analytics", "ui:siemV2/detections", "ui:siemV2/investigation-guide", @@ -5716,9 +5744,12 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/investigation-guide-interactions", "ui:siemV5/threat-intelligence", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/detections", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/detections", ], "policy_management_all": Array [ "login:", @@ -5765,6 +5796,7 @@ if (sourceFilePath === 'authorization.ts') { "api:lists-read", "api:rules-read", "api:alerts-read", + "api:alerts-signal-update-deprecated-privilege", "api:exceptions-read", "api:users-read", "api:initialize-security-solution", @@ -5920,6 +5952,7 @@ if (sourceFilePath === 'authorization.ts') { "saved_object:cloud/open_point_in_time", "saved_object:cloud/close_point_in_time", "ui:siemV2/show", + "ui:siemV2/edit_alerts-update-deprecated-privilege", "ui:siemV2/entity-analytics", "ui:siemV2/detections", "ui:siemV2/investigation-guide", @@ -6174,9 +6207,12 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/threat-intelligence", "ui:siemV5/showEndpointExceptions", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/detections", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/detections", ], "scan_operations_all": Array [ "login:", @@ -7134,11 +7170,15 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/showEndpointExceptions", "ui:siemV5/crudEndpointExceptions", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/edit_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", - "ui:securitySolutionRulesV2/editExceptions", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/edit_rules", + "ui:securitySolutionRulesV3/detections", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/editExceptions", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/edit_alerts", + "ui:securitySolutionAlertsV1/detections", ], "blocklist_all": Array [ "login:", @@ -8200,11 +8240,15 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/investigation-guide-interactions", "ui:siemV5/threat-intelligence", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/edit_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", - "ui:securitySolutionRulesV2/editExceptions", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/edit_rules", + "ui:securitySolutionRulesV3/detections", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/editExceptions", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/edit_alerts", + "ui:securitySolutionAlertsV1/detections", ], "minimal_read": Array [ "login:", @@ -8213,6 +8257,7 @@ if (sourceFilePath === 'authorization.ts') { "api:lists-read", "api:rules-read", "api:alerts-read", + "api:alerts-signal-update-deprecated-privilege", "api:exceptions-read", "api:users-read", "api:initialize-security-solution", @@ -8364,6 +8409,7 @@ if (sourceFilePath === 'authorization.ts') { "saved_object:cloud/open_point_in_time", "saved_object:cloud/close_point_in_time", "ui:siemV3/show", + "ui:siemV3/edit_alerts-update-deprecated-privilege", "ui:siemV3/entity-analytics", "ui:siemV3/detections", "ui:siemV3/investigation-guide", @@ -8617,9 +8663,12 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/investigation-guide-interactions", "ui:siemV5/threat-intelligence", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/detections", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/detections", ], "policy_management_all": Array [ "login:", @@ -8666,6 +8715,7 @@ if (sourceFilePath === 'authorization.ts') { "api:lists-read", "api:rules-read", "api:alerts-read", + "api:alerts-signal-update-deprecated-privilege", "api:exceptions-read", "api:users-read", "api:initialize-security-solution", @@ -8819,6 +8869,7 @@ if (sourceFilePath === 'authorization.ts') { "saved_object:cloud/open_point_in_time", "saved_object:cloud/close_point_in_time", "ui:siemV3/show", + "ui:siemV3/edit_alerts-update-deprecated-privilege", "ui:siemV3/entity-analytics", "ui:siemV3/detections", "ui:siemV3/investigation-guide", @@ -9074,9 +9125,12 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/threat-intelligence", "ui:siemV5/showEndpointExceptions", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/detections", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/detections", ], "scan_operations_all": Array [ "login:", @@ -10062,11 +10116,15 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/investigation-guide-interactions", "ui:siemV5/threat-intelligence", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/edit_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", - "ui:securitySolutionRulesV2/editExceptions", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/edit_rules", + "ui:securitySolutionRulesV3/detections", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/editExceptions", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/edit_alerts", + "ui:securitySolutionAlertsV1/detections", ], "blocklist_all": Array [ "login:", @@ -11129,11 +11187,15 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/investigation-guide-interactions", "ui:siemV5/threat-intelligence", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/edit_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", - "ui:securitySolutionRulesV2/editExceptions", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/edit_rules", + "ui:securitySolutionRulesV3/detections", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/editExceptions", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/edit_alerts", + "ui:securitySolutionAlertsV1/detections", ], "minimal_read": Array [ "login:", @@ -11142,6 +11204,7 @@ if (sourceFilePath === 'authorization.ts') { "api:lists-read", "api:rules-read", "api:alerts-read", + "api:alerts-signal-update-deprecated-privilege", "api:exceptions-read", "api:users-read", "api:initialize-security-solution", @@ -11295,6 +11358,7 @@ if (sourceFilePath === 'authorization.ts') { "saved_object:cloud/open_point_in_time", "saved_object:cloud/close_point_in_time", "ui:siemV4/show", + "ui:siemV4/edit_alerts-update-deprecated-privilege", "ui:siemV4/entity-analytics", "ui:siemV4/detections", "ui:siemV4/investigation-guide", @@ -11547,9 +11611,12 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/investigation-guide-interactions", "ui:siemV5/threat-intelligence", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/detections", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/detections", ], "policy_management_all": Array [ "login:", @@ -11596,6 +11663,7 @@ if (sourceFilePath === 'authorization.ts') { "api:lists-read", "api:rules-read", "api:alerts-read", + "api:alerts-signal-update-deprecated-privilege", "api:exceptions-read", "api:users-read", "api:initialize-security-solution", @@ -11749,6 +11817,7 @@ if (sourceFilePath === 'authorization.ts') { "saved_object:cloud/open_point_in_time", "saved_object:cloud/close_point_in_time", "ui:siemV4/show", + "ui:siemV4/edit_alerts-update-deprecated-privilege", "ui:siemV4/entity-analytics", "ui:siemV4/detections", "ui:siemV4/investigation-guide", @@ -12001,9 +12070,12 @@ if (sourceFilePath === 'authorization.ts') { "ui:siemV5/investigation-guide-interactions", "ui:siemV5/threat-intelligence", "ui:navLinks/securitySolutionRules", - "ui:securitySolutionRulesV2/read_rules", - "ui:securitySolutionRulesV2/readExceptions", - "ui:securitySolutionRulesV2/detections", + "ui:securitySolutionRulesV3/read_rules", + "ui:securitySolutionRulesV3/readExceptions", + "ui:securitySolutionRulesV3/detections", + "ui:navLinks/securitySolutionAlertsV1", + "ui:securitySolutionAlertsV1/read_alerts", + "ui:securitySolutionAlertsV1/detections", ], "scan_operations_all": Array [ "login:",