[Security Solution][Serverless] PLI features base architecture (#158179)

[Documentation](https://docs.google.com/document/d/1Ms8d8d_fbTTRHlBroEAKGNMNk3jFFgOAkVDRhqLxAPQ/edit?pli=1#)


issue: https://github.com/elastic/kibana/issues/158810
## Summary

This PR is a cleanup to make [this
POC](https://github.com/elastic/kibana/pull/155420) production ready

- Serverless PLI features splitting in Security Solution, to allow/deny
access to configured functionalities, using the current Kibana RBAC
service.
- Create the Upselling service to display Serveless-specific prompts in
the application when features are not available
- Create a `SecurityRoutePageWrapper` component that wraps Pages and
displays the upsell when necessary.
- We will refactor the code base to use `SecurityRoutePageWrapper`
everywhere on another PR.
- Create an Upsell page and section for entity analytics


bd8db822-2f4b-4545-9da7-bedc07d93f90


### test:
Serverless: `yarn serverless-security`. 
* To change the product line you have to update
`xpack.serverless.security.productLineIds` on
`config/serverless.security.yml`.

ESS: `yarn start`


### Glossary
* PLI - Product Line Item (`Alert Triage`, `Osquery`, `Cases` , ... )
* Product Line - The product that the user is subscribed to (Security
Essentials, Security Complete, ...)
* essSecurity - New plugin with code that only runs for ESS offer
(non-serverless).
* App Feature - A security solution feature or group of features that
can be disabled for a product line. It can be mapped to PLIs (`Alert
Triage`, `Osquery`, `Cases` , ... ).
* Capability - A string that when present represents that the user can
access a given feature. A capability could be of the type UI or API
(`read_cases`, `crud_cases`, ...).


### Current architecture

![Security
Features](https://user-images.githubusercontent.com/17747913/233414697-231940c2-7790-485b-9403-e971351fa655.jpg)

### New architecture

![Serverless Security
Features](https://user-images.githubusercontent.com/17747913/233414733-1fc0eef1-be20-46ef-8692-bc80867326d1.jpg)

### How does it work?
Every serverless product line (endpointEssentials, cloud essentials) can
define which features are enabled:

69d0fc15f4/x-pack/plugins/serverless_security/common/pli/pli_config.ts (L12-L19)

For ESS (non-serverless) offer we enable all features by default.

69d0fc15f4/x-pack/plugins/ess_security/server/constants.ts (L10-L13)


A feature can define privileges: 

69d0fc15f4/x-pack/plugins/security_solution/server/lib/app_features/security_kibana_features.ts (L177-L185)

When the feature is enabled the privileges get merged into the base
config and injected into kibana features.

69d0fc15f4/x-pack/plugins/security_solution/server/lib/app_features/app_features.ts (L61-L70)


### TODO
- [x] lazy load these components
- [x] Add unit test to:
- ~SecurityRoutePageWrapper
x-pack/plugins/security_solution/public/common/components/security_route_page_wrapper/index.tsx~
-
~x-pack/plugins/security_solution/public/common/hooks/use_upselling.ts~
-
~x-pack/plugins/security_solution/public/common/lib/capabilities/has_capabilities.ts~
-
~x-pack/plugins/security_solution/public/common/lib/upsellings/upselling_service.ts~
  - ~x-pack/plugins/serverless_security/common/pli/pli_features.ts~
-
~x-pack/plugins/serverless_security/public/components/upselling/register_upsellings.tsx~
-
~x-pack/plugins/security_solution/server/lib/app_features/app_features.ts~

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pablo Machado 2023-06-01 19:40:30 +02:00 committed by GitHub
parent 248a1346af
commit 88aa68aec8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
86 changed files with 3019 additions and 909 deletions

1
.github/CODEOWNERS vendored
View file

@ -342,6 +342,7 @@ packages/kbn-eslint-plugin-eslint @elastic/kibana-operations
packages/kbn-eslint-plugin-imports @elastic/kibana-operations
packages/kbn-eslint-plugin-telemetry @elastic/actionable-observability
x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin @elastic/kibana-security
x-pack/plugins/ess_security @elastic/security-solution
src/plugins/event_annotation @elastic/kibana-visualizations
x-pack/test/plugin_api_integration/plugins/event_log @elastic/response-ops
x-pack/plugins/event_log @elastic/response-ops

View file

@ -8,6 +8,7 @@ xpack.uptime.enabled: false
## Enable the Serverless Security plugin
xpack.serverless.security.enabled: true
xpack.serverless.security.productLineIds: ['securityComplete']
## Set the home route
uiSettings.overrides.defaultRoute: /app/security/get_started

View file

@ -4,6 +4,9 @@ xpack.serverless.plugin.enabled: true
xpack.fleet.internal.fleetServerStandalone: true
xpack.fleet.internal.disableILMPolicies: true
# Ess plugins
xpack.ess.security.enabled: false
# Management team plugins
xpack.upgrade_assistant.enabled: false
xpack.rollup.enabled: false

View file

@ -534,6 +534,10 @@ security and spaces filtering.
|This plugin provides Kibana user interfaces for managing the Enterprise Search solution and its products, App Search and Workplace Search.
|{kib-repo}blob/{branch}/x-pack/plugins/ess_security/README.md[essSecurity]
|This plugin contains the ESS/on-prem deployments (non-serverless) customizations for Security Solution.
|{kib-repo}blob/{branch}/x-pack/plugins/event_log/README.md[eventLog]
|The event log plugin provides a persistent history of alerting and action
activities.

View file

@ -375,6 +375,7 @@
"@kbn/es-types": "link:packages/kbn-es-types",
"@kbn/es-ui-shared-plugin": "link:src/plugins/es_ui_shared",
"@kbn/eso-plugin": "link:x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin",
"@kbn/ess-security": "link:x-pack/plugins/ess_security",
"@kbn/event-annotation-plugin": "link:src/plugins/event_annotation",
"@kbn/event-log-fixture-plugin": "link:x-pack/test/plugin_api_integration/plugins/event_log",
"@kbn/event-log-plugin": "link:x-pack/plugins/event_log",

View file

@ -38,6 +38,7 @@ pageLoadAssetSize:
embeddable: 87309
embeddableEnhanced: 22107
enterpriseSearch: 35741
essSecurity: 16573
esUiShared: 326654
eventAnnotation: 22000
exploratoryView: 74673

View file

@ -678,6 +678,8 @@
"@kbn/eslint-plugin-telemetry/*": ["packages/kbn-eslint-plugin-telemetry/*"],
"@kbn/eso-plugin": ["x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin"],
"@kbn/eso-plugin/*": ["x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin/*"],
"@kbn/ess-security": ["x-pack/plugins/ess_security"],
"@kbn/ess-security/*": ["x-pack/plugins/ess_security/*"],
"@kbn/event-annotation-plugin": ["src/plugins/event_annotation"],
"@kbn/event-annotation-plugin/*": ["src/plugins/event_annotation/*"],
"@kbn/event-log-fixture-plugin": ["x-pack/test/plugin_api_integration/plugins/event_log"],

View file

@ -0,0 +1,2 @@
/build
/target

View file

@ -0,0 +1,3 @@
# essSecurity
This plugin contains the ESS/on-prem deployments (non-serverless) customizations for Security Solution.

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const PLUGIN_ID = 'essSecurity';
export const PLUGIN_NAME = 'essSecurity';

View file

@ -0,0 +1,17 @@
{
"type": "plugin",
"id": "@kbn/ess-security",
"owner": "@elastic/security-solution",
"description": "ESS customizations for Security Solution.",
"plugin": {
"id": "essSecurity",
"server": true,
"browser": true,
"configPath": ["xpack", "ess", "security"],
"requiredPlugins": [
"securitySolution",
],
"optionalPlugins": [],
"requiredBundles": []
}
}

View file

@ -0,0 +1,11 @@
{
"name": "@kbn/ess-security",
"version": "1.0.0",
"license": "Elastic License 2.0",
"private": true,
"scripts": {
"build": "yarn plugin-helpers build",
"plugin-helpers": "node ../../../scripts/plugin_helpers",
"kbn": "node ../../../scripts/kbn"
}
}

View file

@ -0,0 +1,17 @@
/*
* 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 { PluginInitializerContext } from '@kbn/core/public';
import { EssSecurityPlugin } from './plugin';
// This exports static code and TypeScript types,
// as well as, Kibana Platform `plugin()` initializer.
export function plugin(_initializerContext: PluginInitializerContext) {
return new EssSecurityPlugin();
}
export type { EssSecurityPluginSetup, EssSecurityPluginStart } from './types';

View file

@ -0,0 +1,42 @@
/*
* 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 { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import {
EssSecurityPluginSetup,
EssSecurityPluginStart,
EssSecurityPluginSetupDependencies,
EssSecurityPluginStartDependencies,
} from './types';
export class EssSecurityPlugin
implements
Plugin<
EssSecurityPluginSetup,
EssSecurityPluginStart,
EssSecurityPluginSetupDependencies,
EssSecurityPluginStartDependencies
>
{
constructor() {}
public setup(
_core: CoreSetup,
_setupDeps: EssSecurityPluginSetupDependencies
): EssSecurityPluginSetup {
return {};
}
public start(
_core: CoreStart,
_startDeps: EssSecurityPluginStartDependencies
): EssSecurityPluginStart {
return {};
}
public stop() {}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
PluginSetup as SecuritySolutionPluginSetup,
PluginStart as SecuritySolutionPluginStart,
} from '@kbn/security-solution-plugin/public';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface EssSecurityPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface EssSecurityPluginStart {}
export interface EssSecurityPluginSetupDependencies {
securitySolution: SecuritySolutionPluginSetup;
}
export interface EssSecurityPluginStartDependencies {
securitySolution: SecuritySolutionPluginStart;
}

View file

@ -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 { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core/server';
export const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }),
});
export type EssSecurityConfig = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<EssSecurityConfig> = {
schema: configSchema,
};

View file

@ -0,0 +1,13 @@
/*
* 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 { AppFeatureKey, AppFeatureKeys } from '@kbn/security-solution-plugin/common';
export const DEFAULT_APP_FEATURES: AppFeatureKeys = {
[AppFeatureKey.advancedInsights]: true,
[AppFeatureKey.casesConnectors]: true,
};

View file

@ -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.
*/
import { PluginInitializerContext } from '@kbn/core/server';
import { EssSecurityPlugin } from './plugin';
export { config } from './config';
// This exports static code and TypeScript types,
// as well as, Kibana Platform `plugin()` initializer.
export function plugin(_initializerContext: PluginInitializerContext) {
return new EssSecurityPlugin();
}
export type { EssSecurityPluginSetup, EssSecurityPluginStart } from './types';

View file

@ -0,0 +1,39 @@
/*
* 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 { Plugin, CoreSetup } from '@kbn/core/server';
import { DEFAULT_APP_FEATURES } from './constants';
import {
EssSecurityPluginSetup,
EssSecurityPluginStart,
EssSecurityPluginSetupDependencies,
EssSecurityPluginStartDependencies,
} from './types';
export class EssSecurityPlugin
implements
Plugin<
EssSecurityPluginSetup,
EssSecurityPluginStart,
EssSecurityPluginSetupDependencies,
EssSecurityPluginStartDependencies
>
{
constructor() {}
public setup(_coreSetup: CoreSetup, pluginsSetup: EssSecurityPluginSetupDependencies) {
pluginsSetup.securitySolution.setAppFeatures(DEFAULT_APP_FEATURES);
return {};
}
public start() {
return {};
}
public stop() {}
}

View file

@ -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 {
PluginSetup as SecuritySolutionPluginSetup,
PluginStart as SecuritySolutionPluginStart,
} from '@kbn/security-solution-plugin/server';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface EssSecurityPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface EssSecurityPluginStart {}
export interface EssSecurityPluginSetupDependencies {
securitySolution: SecuritySolutionPluginSetup;
}
export interface EssSecurityPluginStartDependencies {
securitySolution: SecuritySolutionPluginStart;
}

View file

@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"index.ts",
"common/**/*.ts",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
"../../../typings/**/*"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/config-schema",
"@kbn/security-solution-plugin",
]
}

View file

@ -8,8 +8,11 @@
// TODO(jbudz): should be removed when upgrading to TS@4.8
// this is a skip for the errors created when typechecking with isolatedModules
export {};
export { APP_UI_ID, SecurityPageName } from './constants';
export { APP_UI_ID, APP_ID, CASES_FEATURE_ID, SERVER_APP_ID, SecurityPageName } from './constants';
export { ELASTIC_SECURITY_RULE_ID } from './detection_engine/constants';
export { allowedExperimentalValues, type ExperimentalFeatures } from './experimental_features';
export type { AppFeatureKeys } from './types/app_features';
export { AppFeatureKey } from './types/app_features';
// Careful of exporting anything from this file as any file(s) you export here will cause your page bundle size to increase.
// If you're using functions/types/etc... internally it's best to import directly from their paths than expose the functions/types/etc... here.

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export enum AppFeatureSecurityKey {
/**
* Enables Advanced Insights (Entity Risk, GenAI)
*/
advancedInsights = 'advanced_insights',
}
export enum AppFeatureCasesKey {
/**
* Enables Cases Connectors
*/
casesConnectors = 'cases_connectors',
}
// Merges the two enums.
// We need to merge the value and the type and export both to replicate how enum works.
export const AppFeatureKey = { ...AppFeatureSecurityKey, ...AppFeatureCasesKey };
export type AppFeatureKey = AppFeatureSecurityKey | AppFeatureCasesKey;
type AppFeatureSecurityKeys = { [key in AppFeatureSecurityKey]: boolean };
type AppFeatureCasesKeys = { [key in AppFeatureCasesKey]: boolean };
export type AppFeatureKeys = AppFeatureSecurityKeys & AppFeatureCasesKeys;

View file

@ -14,6 +14,26 @@ export interface DescriptionList {
description: NonNullable<ReactNode>;
}
// Recursive partial object type. inspired by EUI
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends NonAny[]
? T[P]
: T[P] extends readonly NonAny[]
? T[P]
: T[P] extends Array<infer U>
? Array<RecursivePartial<U>>
: T[P] extends ReadonlyArray<infer U>
? ReadonlyArray<RecursivePartial<U>>
: T[P] extends Set<infer V>
? Set<RecursivePartial<V>>
: T[P] extends Map<infer K, infer V>
? Map<K, RecursivePartial<V>>
: T[P] extends NonAny
? T[P]
: RecursivePartial<T[P]>;
};
type NonAny = number | boolean | string | symbol | null;
export const unionWithNullType = <T extends runtimeTypes.Mixed>(type: T) =>
runtimeTypes.union([type, runtimeTypes.null]);

View file

@ -21,6 +21,7 @@ import { updateAppLinks } from '../../../links';
import { allowedExperimentalValues } from '../../../../../common/experimental_features';
import { AlertDetailRouteType } from '../../../../detections/pages/alert_details/types';
import { UsersTableType } from '../../../../explore/users/store/model';
import { UpsellingService } from '../../../lib/upsellings';
const mockUseRouteSpy = jest.fn();
jest.mock('../../../utils/route/use_route_spy', () => ({
@ -171,6 +172,7 @@ describe('Navigation Breadcrumbs', () => {
crud: true,
},
},
upselling: new UpsellingService(),
});
});

View file

@ -11,6 +11,7 @@ import type { AppLinkItems } from '../../links';
import { updateAppLinks } from '../../links';
import { mockGlobalState } from '../../mock';
import type { Capabilities } from '@kbn/core-capabilities-common';
import { UpsellingService } from '../../lib/upsellings';
const defaultAppLinks: AppLinkItems = [
{
@ -27,11 +28,14 @@ const defaultAppLinks: AppLinkItems = [
},
];
const mockUpselling = new UpsellingService();
describe('helpers', () => {
beforeAll(() => {
updateAppLinks(defaultAppLinks, {
capabilities: {} as unknown as Capabilities,
experimentalFeatures: mockGlobalState.app.enableExperimental,
upselling: mockUpselling,
});
});
it('returns the search string', () => {

View file

@ -0,0 +1,88 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { Router } from 'react-router-dom';
import { SecurityRoutePageWrapper } from '.';
import { SecurityPageName } from '../../../../common';
import { TestProviders } from '../../mock';
import { generateHistoryMock } from '../../utils/route/mocks';
const mockUseLinkAuthorized = jest.fn();
const mockUseUpsellingPage = jest.fn();
jest.mock('../../links', () => ({
useLinkAuthorized: () => mockUseLinkAuthorized(),
}));
jest.mock('../../hooks/use_upselling', () => ({
useUpsellingPage: () => mockUseUpsellingPage(),
}));
const TEST_COMPONENT_SUBJ = 'test-component';
const TestComponent = () => <div data-test-subj={TEST_COMPONENT_SUBJ} />;
const mockHistory = generateHistoryMock();
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<Router history={mockHistory}>
<TestProviders>{children}</TestProviders>
</Router>
);
describe('SecurityRoutePageWrapper', () => {
it('renders children when authorized', () => {
mockUseLinkAuthorized.mockReturnValue(true);
const { getByTestId } = render(
<Router history={mockHistory}>
<SecurityRoutePageWrapper pageName={SecurityPageName.exploreLanding}>
<TestComponent />
</SecurityRoutePageWrapper>
</Router>,
{ wrapper: Wrapper }
);
expect(getByTestId(TEST_COMPONENT_SUBJ)).toBeInTheDocument();
});
it('renders UpsellPage when unauthorized and UpsellPage is available', () => {
const TestUpsellPage = () => <div data-test-subj={'test-upsell-page'} />;
mockUseLinkAuthorized.mockReturnValue(false);
mockUseUpsellingPage.mockReturnValue(TestUpsellPage);
const { getByTestId } = render(
<Router history={mockHistory}>
<SecurityRoutePageWrapper pageName={SecurityPageName.exploreLanding}>
<TestComponent />
</SecurityRoutePageWrapper>
</Router>,
{ wrapper: Wrapper }
);
expect(getByTestId('test-upsell-page')).toBeInTheDocument();
});
it('renders NoPrivilegesPage when unauthorized and UpsellPage is unavailable', () => {
mockUseLinkAuthorized.mockReturnValue(false);
mockUseUpsellingPage.mockReturnValue(undefined);
const { getByTestId } = render(
<Router history={mockHistory}>
<SecurityRoutePageWrapper pageName={SecurityPageName.exploreLanding}>
<TestComponent />
</SecurityRoutePageWrapper>
</Router>,
{ wrapper: Wrapper }
);
expect(getByTestId('noPrivilegesPage')).toBeInTheDocument();
});
});
// Write unit test for file /Users/pablo.nevesmachado/workspace/kibana/x-pack/plugins/security_solution/public/common/components/security_route_page_wrapper/index.tsx

View file

@ -0,0 +1,66 @@
/*
* 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 { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
import type { SecurityPageName } from '../../../../common';
import { useLinkAuthorized } from '../../links';
import { NoPrivilegesPage } from '../no_privileges';
import { useUpsellingPage } from '../../hooks/use_upselling';
import { SpyRoute } from '../../utils/route/spy_routes';
interface SecurityRoutePageWrapperProps {
pageName: SecurityPageName;
}
/**
* This component is created to wrap all the pages in the security solution app.
*
* It handles application tracking and upselling.
*
* When using this component make sure it render bellow `SecurityPageWrapper` and
* that you removed the `TrackApplicationView` component.
*
* Ex:
* ```
* <PluginTemplateWrapper>
* <SecurityRoutePageWrapper pageName={SecurityPageName.myPage}>
* <MyPage />
* </SecurityRoutePageWrapper>
* </PluginTemplateWrapper>
* ```
*/
export const SecurityRoutePageWrapper: React.FC<SecurityRoutePageWrapperProps> = ({
children,
pageName,
}) => {
const isAuthorized = useLinkAuthorized(pageName);
const UpsellPage = useUpsellingPage(pageName);
if (isAuthorized) {
return <TrackApplicationView viewId={pageName}>{children}</TrackApplicationView>;
}
if (UpsellPage) {
return (
<>
<SpyRoute pageName={pageName} />
<UpsellPage />
</>
);
}
return (
<>
<SpyRoute pageName={pageName} />
<NoPrivilegesPage
pageName={pageName}
docLinkSelector={(docLinks) => docLinks.siem.privileges}
/>
</>
);
};

View file

@ -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 { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { SecurityPageName } from '../../../common';
import { UpsellingService } from '../lib/upsellings';
import { useUpsellingComponent, useUpsellingPage } from './use_upselling';
const mockUpselling = new UpsellingService();
jest.mock('../lib/kibana', () => {
const original = jest.requireActual('../lib/kibana');
return {
...original,
useKibana: () => ({
...original.useKibana(),
services: {
...original.useKibana().services,
upselling: mockUpselling,
},
}),
};
});
const TestComponent = () => <div>{'TEST 1 2 3'}</div>;
describe('use_upselling', () => {
test('useUpsellingComponent returns sections', () => {
mockUpselling.registerSections({
entity_analytics_panel: TestComponent,
});
const { result } = renderHook(() => useUpsellingComponent('entity_analytics_panel'));
expect(result.current).toBe(TestComponent);
});
test('useUpsellingPage returns pages', () => {
mockUpselling.registerPages({
[SecurityPageName.hosts]: TestComponent,
});
const { result } = renderHook(() => useUpsellingPage(SecurityPageName.hosts));
expect(result.current).toBe(TestComponent);
});
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable';
import type { UpsellingSectionId } from '../lib/upsellings';
import { useKibana } from '../lib/kibana';
import type { SecurityPageName } from '../../../common';
export const useUpsellingComponent = (id: UpsellingSectionId): React.ComponentType | null => {
const { upselling } = useKibana().services;
const upsellingSections = useObservable(upselling.sections$);
return useMemo(() => upsellingSections?.get(id) ?? null, [id, upsellingSections]);
};
export const useUpsellingPage = (pageName: SecurityPageName): React.ComponentType | null => {
const { upselling } = useKibana().services;
const UpsellingPage = useMemo(() => upselling.getPageUpselling(pageName), [pageName, upselling]);
return UpsellingPage ?? null;
};

View file

@ -0,0 +1,75 @@
/*
* 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 { hasCapabilities } from './has_capabilities';
const EMPTY_CAPABILITIES = { navLinks: {}, management: {}, catalogue: {} };
const SAMPLE_CAPABILITY = { show: true, crud: true };
describe('hasCapabilities', () => {
it('returns true when no capabilities are required', () => {
expect(hasCapabilities(EMPTY_CAPABILITIES)).toEqual(true);
});
describe('when requiredCapabilities is a string', () => {
it('returns false when the capability is not present', () => {
expect(hasCapabilities(EMPTY_CAPABILITIES, 'missingCapability')).toEqual(false);
});
it('returns true when the capability is present', () => {
const capabilities = {
...EMPTY_CAPABILITIES,
requiredCapability: SAMPLE_CAPABILITY,
};
expect(hasCapabilities(capabilities, 'requiredCapability')).toEqual(true);
});
});
describe('when requiredCapabilities is an array', () => {
describe('when there is only one array (OR)', () => {
it('returns false when none of the capabilities are present', () => {
expect(
hasCapabilities(EMPTY_CAPABILITIES, ['missingCapability1', 'missingCapability2'])
).toEqual(false);
});
it('returns true when any of the capabilities are present', () => {
const capabilities = {
...EMPTY_CAPABILITIES,
requiredCapability: SAMPLE_CAPABILITY,
};
expect(hasCapabilities(capabilities, ['requiredCapability', 'missingCapability'])).toEqual(
true
);
});
});
describe('when there subArrays (And)', () => {
it('returns false when one of the capabilities is not present', () => {
const capabilities = {
...EMPTY_CAPABILITIES,
requiredCapability1: SAMPLE_CAPABILITY,
};
expect(
hasCapabilities(capabilities, [['requiredCapability1', 'requiredCapability2']])
).toEqual(false);
});
it('returns true when both capabilities are present', () => {
const capabilities = {
...EMPTY_CAPABILITIES,
requiredCapability1: SAMPLE_CAPABILITY,
requiredCapability2: SAMPLE_CAPABILITY,
};
expect(
hasCapabilities(capabilities, [['requiredCapability1', 'requiredCapability2']])
).toEqual(true);
});
});
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 { get, isArray } from 'lodash';
import type { Capabilities } from '@kbn/core/public';
/**
* The format of defining features supports OR and AND mechanism. To specify features in an OR fashion
* they can be defined in a single level array like: [requiredFeature1, requiredFeature2]. If either of these features
* is satisfied the link would be included. To require that the features be AND'd together a second level array
* can be specified: [feature1, [feature2, feature3]] this would result in feature1 || (feature2 && feature3). To specify
* features that all must be and'd together an example would be: [[feature1, feature2]], this would result in the boolean
* operation feature1 && feature2.
*
* The final format is to specify a single feature, this would be like: features: feature1, which is the same as
* features: [feature1]
*/
export type RequiredCapabilities = string | Array<string | string[]>;
export const hasCapabilities = (
capabilities: Capabilities,
requiredCapabilities?: RequiredCapabilities
): boolean => {
if (!requiredCapabilities) {
return true;
}
if (!isArray(requiredCapabilities)) {
return !!get(capabilities, requiredCapabilities, false);
} else {
return requiredCapabilities.some((linkCapabilityKeyOr) => {
if (isArray(linkCapabilityKeyOr)) {
return linkCapabilityKeyOr.every((linkCapabilityKeyAnd) =>
get(capabilities, linkCapabilityKeyAnd, false)
);
}
return get(capabilities, linkCapabilityKeyOr, false);
});
}
};

View file

@ -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 { hasCapabilities, type RequiredCapabilities } from './has_capabilities';

View file

@ -47,6 +47,7 @@ import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks
import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { of } from 'rxjs';
import { UpsellingService } from '../upsellings';
const mockUiSettings: Record<string, unknown> = {
[DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' },
@ -191,6 +192,7 @@ export const createStartServicesMock = (
cloudExperiments,
guidedOnboarding,
isSidebarEnabled$: of(true),
upselling: new UpsellingService(),
} as unknown as StartServices;
};

View file

@ -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 { UpsellingService } from './upselling_service';
export type { PageUpsellings, SectionUpsellings, UpsellingSectionId } from './types';

View file

@ -0,0 +1,13 @@
/*
* 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 { SecurityPageName } from '../../../../common';
export type PageUpsellings = Partial<Record<SecurityPageName, React.ComponentType>>;
export type SectionUpsellings = Partial<Record<UpsellingSectionId, React.ComponentType>>;
export type UpsellingSectionId = 'entity_analytics_panel';

View file

@ -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 React from 'react';
import { firstValueFrom } from 'rxjs';
import { SecurityPageName } from '../../../../common';
import { UpsellingService } from './upselling_service';
const TestComponent = () => <div>{'TEST component'}</div>;
describe('UpsellingService', () => {
it('registers sections', async () => {
const service = new UpsellingService();
service.registerSections({
entity_analytics_panel: TestComponent,
});
const value = await firstValueFrom(service.sections$);
expect(value.get('entity_analytics_panel')).toEqual(TestComponent);
});
it('registers pages', async () => {
const service = new UpsellingService();
service.registerPages({
[SecurityPageName.hosts]: TestComponent,
});
const value = await firstValueFrom(service.pages$);
expect(value.get(SecurityPageName.hosts)).toEqual(TestComponent);
});
it('"isPageUpsellable" returns true when page is upsellable', () => {
const service = new UpsellingService();
service.registerPages({
[SecurityPageName.hosts]: TestComponent,
});
expect(service.isPageUpsellable(SecurityPageName.hosts)).toEqual(true);
});
it('"getPageUpselling" returns page component when page is upsellable', () => {
const service = new UpsellingService();
service.registerPages({
[SecurityPageName.hosts]: TestComponent,
});
expect(service.getPageUpselling(SecurityPageName.hosts)).toEqual(TestComponent);
});
});

View file

@ -0,0 +1,52 @@
/*
* 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 { Observable } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import type { SecurityPageName } from '../../../../common';
import type { SectionUpsellings, PageUpsellings, UpsellingSectionId } from './types';
export class UpsellingService {
private sections: Map<UpsellingSectionId, React.ComponentType>;
private pages: Map<SecurityPageName, React.ComponentType>;
private sectionsSubject$: BehaviorSubject<Map<UpsellingSectionId, React.ComponentType>>;
private pagesSubject$: BehaviorSubject<Map<SecurityPageName, React.ComponentType>>;
public sections$: Observable<Map<UpsellingSectionId, React.ComponentType>>;
public pages$: Observable<Map<SecurityPageName, React.ComponentType>>;
constructor() {
this.sections = new Map();
this.sectionsSubject$ = new BehaviorSubject(new Map());
this.sections$ = this.sectionsSubject$.asObservable();
this.pages = new Map();
this.pagesSubject$ = new BehaviorSubject(new Map());
this.pages$ = this.pagesSubject$.asObservable();
}
registerSections(sections: SectionUpsellings) {
Object.entries(sections).forEach(([sectionId, component]) => {
this.sections.set(sectionId as UpsellingSectionId, component);
});
this.sectionsSubject$.next(this.sections);
}
registerPages(pages: PageUpsellings) {
Object.entries(pages).forEach(([pageId, component]) => {
this.pages.set(pageId as SecurityPageName, component);
});
this.pagesSubject$.next(this.pages);
}
isPageUpsellable(id: SecurityPageName) {
return this.pages.has(id);
}
getPageUpselling(id: SecurityPageName) {
return this.pages.get(id);
}
}

View file

@ -18,9 +18,11 @@ import {
needsUrlState,
updateAppLinks,
useLinkExists,
hasCapabilities,
} from './links';
import { createCapabilities } from './test_utils';
import { hasCapabilities } from '../lib/capabilities';
import { UpsellingService } from '../lib/upsellings';
import React from 'react';
const defaultAppLinks: AppLinkItems = [
{
@ -43,6 +45,8 @@ const defaultAppLinks: AppLinkItems = [
},
];
const mockUpselling = new UpsellingService();
const mockExperimentalDefaults = mockGlobalState.app.enableExperimental;
const mockCapabilities = {
@ -90,6 +94,7 @@ describe('Security links', () => {
capabilities: mockCapabilities,
experimentalFeatures: mockExperimentalDefaults,
license: mockLicense,
upselling: mockUpselling,
});
});
@ -168,6 +173,7 @@ describe('Security links', () => {
flagDisabled: false,
} as unknown as typeof mockExperimentalDefaults,
license: { hasAtLeast: licenseBasicMock } as unknown as ILicense,
upselling: mockUpselling,
}
);
await waitForNextUpdate();
@ -175,6 +181,74 @@ describe('Security links', () => {
expect(result.current).toStrictEqual([networkLinkItem]);
});
it('should return unauthorized page when page has upselling', async () => {
const upselling = new UpsellingService();
upselling.registerPages({ [SecurityPageName.network]: () => <span /> });
const { result, waitForNextUpdate } = renderUseAppLinks();
const networkLinkItem = {
id: SecurityPageName.network,
title: 'Network',
path: '/network',
capabilities: [`${CASES_FEATURE_ID}.read_cases`, `${CASES_FEATURE_ID}.write_cases`],
experimentalKey: 'flagEnabled' as unknown as keyof typeof mockExperimentalDefaults,
hideWhenExperimentalKey: 'flagDisabled' as unknown as keyof typeof mockExperimentalDefaults,
licenseType: 'basic' as const,
};
await act(async () => {
updateAppLinks(
[
{
...networkLinkItem,
// The following links should be filtered out because network link is unauthorized
links: [
{
id: SecurityPageName.networkDns,
title: 'dns',
path: '/dns',
},
{
id: SecurityPageName.networkHttp,
title: 'Http',
path: '/http',
},
],
},
{
// should be excluded by license with all its links
id: SecurityPageName.hosts,
title: 'Hosts',
path: '/hosts',
licenseType: 'platinum',
links: [
{
id: SecurityPageName.hostsEvents,
title: 'Events',
path: '/events',
},
],
},
],
{
capabilities: {
...mockCapabilities,
[CASES_FEATURE_ID]: { read_cases: false, crud_cases: false },
},
experimentalFeatures: {
flagEnabled: true,
flagDisabled: false,
} as unknown as typeof mockExperimentalDefaults,
license: { hasAtLeast: licenseBasicMock } as unknown as ILicense,
upselling,
}
);
await waitForNextUpdate();
});
expect(result.current).toStrictEqual([{ ...networkLinkItem, unauthorized: true }]);
});
});
describe('useLinkExists', () => {
@ -204,6 +278,7 @@ describe('Security links', () => {
capabilities: mockCapabilities,
experimentalFeatures: mockExperimentalDefaults,
license: mockLicense,
upselling: new UpsellingService(),
}
);
await waitForNextUpdate();
@ -234,6 +309,7 @@ describe('Security links', () => {
capabilities: mockCapabilities,
experimentalFeatures: mockExperimentalDefaults,
license: mockLicense,
upselling: mockUpselling,
}
);
await waitForNextUpdate();
@ -291,33 +367,33 @@ describe('Security links', () => {
const pushCases = 'securitySolutionCases.push_cases';
it('returns false when capabilities is an empty array', () => {
expect(hasCapabilities([], createCapabilities())).toBeFalsy();
expect(hasCapabilities(createCapabilities(), [])).toBeFalsy();
});
it('returns true when the capability requested is specified as a single value', () => {
expect(hasCapabilities(siemShow, createCapabilities({ siem: { show: true } }))).toBeTruthy();
expect(hasCapabilities(createCapabilities({ siem: { show: true } }), siemShow)).toBeTruthy();
});
it('returns true when the capability requested is a single entry in an array', () => {
expect(
hasCapabilities([siemShow], createCapabilities({ siem: { show: true } }))
hasCapabilities(createCapabilities({ siem: { show: true } }), [siemShow])
).toBeTruthy();
});
it("returns true when the capability requested is a single entry in an AND'd array format", () => {
expect(
hasCapabilities([[siemShow]], createCapabilities({ siem: { show: true } }))
hasCapabilities(createCapabilities({ siem: { show: true } }), [[siemShow]])
).toBeTruthy();
});
it('returns true when only one requested capability is found in an OR situation', () => {
expect(
hasCapabilities(
[siemShow, createCases],
createCapabilities({
siem: { show: true },
securitySolutionCases: { create_cases: false },
})
}),
[siemShow, createCases]
)
).toBeTruthy();
});
@ -325,11 +401,11 @@ describe('Security links', () => {
it('returns true when only the create_cases requested capability is found in an OR situation', () => {
expect(
hasCapabilities(
[siemShow, createCases],
createCapabilities({
siem: { show: false },
securitySolutionCases: { create_cases: true },
})
}),
[siemShow, createCases]
)
).toBeTruthy();
});
@ -337,11 +413,11 @@ describe('Security links', () => {
it('returns false when none of the requested capabilities are found in an OR situation', () => {
expect(
hasCapabilities(
[readCases, createCases],
createCapabilities({
siem: { show: true },
securitySolutionCases: { create_cases: false },
})
}),
[readCases, createCases]
)
).toBeFalsy();
});
@ -349,11 +425,11 @@ describe('Security links', () => {
it('returns true when all of the requested capabilities are found in an AND situation', () => {
expect(
hasCapabilities(
[[readCases, createCases]],
createCapabilities({
siem: { show: true },
securitySolutionCases: { read_cases: true, create_cases: true },
})
}),
[[readCases, createCases]]
)
).toBeTruthy();
});
@ -361,11 +437,11 @@ describe('Security links', () => {
it('returns false when neither the single OR capability is found nor all of the AND capabilities', () => {
expect(
hasCapabilities(
[siemShow, [readCases, createCases]],
createCapabilities({
siem: { show: false },
securitySolutionCases: { read_cases: false, create_cases: true },
})
}),
[siemShow, [readCases, createCases]]
)
).toBeFalsy();
});
@ -373,11 +449,11 @@ describe('Security links', () => {
it('returns true when the single OR capability is found when using an OR with an AND format', () => {
expect(
hasCapabilities(
[siemShow, [readCases, createCases]],
createCapabilities({
siem: { show: true },
securitySolutionCases: { read_cases: false, create_cases: true },
})
}),
[siemShow, [readCases, createCases]]
)
).toBeTruthy();
});
@ -385,14 +461,14 @@ describe('Security links', () => {
it("returns false when the AND'd expressions are not satisfied", () => {
expect(
hasCapabilities(
[
[siemShow, pushCases],
[readCases, createCases],
],
createCapabilities({
siem: { show: true },
securitySolutionCases: { read_cases: false, create_cases: true, push_cases: false },
})
}),
[
[siemShow, pushCases],
[readCases, createCases],
]
)
).toBeFalsy();
});

View file

@ -4,13 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Capabilities } from '@kbn/core/public';
import get from 'lodash/get';
import { useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject } from 'rxjs';
import type { SecurityPageName } from '../../../common/constants';
import { hasCapabilities } from '../lib/capabilities';
import type {
AppLinkItems,
LinkInfo,
@ -40,9 +38,9 @@ export const updateAppLinks = (
appLinksToUpdate: AppLinkItems,
linksPermissions: LinksPermissions
) => {
const filteredAppLinks = getFilteredAppLinks(appLinksToUpdate, linksPermissions);
appLinksUpdater$.next(Object.freeze(filteredAppLinks));
normalizedAppLinksUpdater$.next(Object.freeze(getNormalizedLinks(filteredAppLinks)));
const appLinks = processAppLinks(appLinksToUpdate, linksPermissions);
appLinksUpdater$.next(Object.freeze(appLinks));
normalizedAppLinksUpdater$.next(Object.freeze(getNormalizedLinks(appLinks)));
};
/**
@ -65,6 +63,28 @@ export const useLinkExists = (id: SecurityPageName): boolean => {
return useMemo(() => !!normalizedLinks[id], [normalizedLinks, id]);
};
export const useLinkInfo = (id: SecurityPageName): LinkInfo | undefined => {
const normalizedLinks = useNormalizedAppLinks();
return useMemo(() => {
const normalizedLink = normalizedLinks[id];
if (!normalizedLink) {
return undefined;
}
// discards the parentId and creates the linkInfo copy.
const { parentId, ...linkInfo } = normalizedLink;
return linkInfo;
}, [normalizedLinks, id]);
};
/**
* Hook to check if a link exists in the application links,
* It can be used to know if a link access is authorized.
*/
export const useLinkAuthorized = (id: SecurityPageName): boolean => {
const linkInfo = useLinkInfo(id);
return useMemo(() => linkInfo != null && !linkInfo.unauthorized, [linkInfo]);
};
/**
* Returns the `LinkInfo` from a link id parameter
*/
@ -132,61 +152,31 @@ const getNormalizedLinks = (
const getNormalizedLink = (id: SecurityPageName): Readonly<NormalizedLink> | undefined =>
normalizedAppLinksUpdater$.getValue()[id];
const getFilteredAppLinks = (
appLinkToFilter: AppLinkItems,
linksPermissions: LinksPermissions
): LinkItem[] =>
appLinkToFilter.reduce<LinkItem[]>((acc, { links, ...appLink }) => {
if (!isLinkAllowed(appLink, linksPermissions)) {
const processAppLinks = (appLinks: AppLinkItems, linksPermissions: LinksPermissions): LinkItem[] =>
appLinks.reduce<LinkItem[]>((acc, { links, ...appLinkWithoutSublinks }) => {
if (!isLinkAllowed(appLinkWithoutSublinks, linksPermissions)) {
return acc;
}
if (links) {
const childrenLinks = getFilteredAppLinks(links, linksPermissions);
if (childrenLinks.length > 0) {
acc.push({ ...appLink, links: childrenLinks });
} else {
acc.push(appLink);
if (!hasCapabilities(linksPermissions.capabilities, appLinkWithoutSublinks.capabilities)) {
if (linksPermissions.upselling.isPageUpsellable(appLinkWithoutSublinks.id)) {
acc.push({ ...appLinkWithoutSublinks, unauthorized: true });
}
} else {
acc.push(appLink);
return acc; // not adding sub-links for links that are not authorized
}
const resultAppLink: LinkItem = appLinkWithoutSublinks;
if (links) {
const childrenLinks = processAppLinks(links, linksPermissions);
if (childrenLinks.length > 0) {
resultAppLink.links = childrenLinks;
}
}
acc.push(resultAppLink);
return acc;
}, []);
/**
* The format of defining features supports OR and AND mechanism. To specify features in an OR fashion
* they can be defined in a single level array like: [requiredFeature1, requiredFeature2]. If either of these features
* is satisfied the links would be included. To require that the features be AND'd together a second level array
* can be specified: [feature1, [feature2, feature3]] this would result in feature1 || (feature2 && feature3).
*
* The final format is to specify a single feature, this would be like: features: feature1, which is the same as
* features: [feature1]
*/
type LinkCapabilities = string | Array<string | string[]>;
// It checks if the user has at least one of the link capabilities needed
export const hasCapabilities = <T>(
linkCapabilities: LinkCapabilities,
userCapabilities: Capabilities
): boolean => {
if (!Array.isArray(linkCapabilities)) {
return !!get(userCapabilities, linkCapabilities, false);
} else {
return linkCapabilities.some((linkCapabilityKeyOr) => {
if (Array.isArray(linkCapabilityKeyOr)) {
return linkCapabilityKeyOr.every((linkCapabilityKeyAnd) =>
get(userCapabilities, linkCapabilityKeyAnd, false)
);
}
return get(userCapabilities, linkCapabilityKeyOr, false);
});
}
};
const isLinkAllowed = (
link: LinkItem,
{ license, experimentalFeatures, capabilities }: LinksPermissions
) => {
const isLinkAllowed = (link: LinkItem, { license, experimentalFeatures }: LinksPermissions) => {
const linkLicenseType = link.licenseType ?? 'basic';
if (license) {
if (!license.hasAtLeast(linkLicenseType)) {
@ -201,8 +191,5 @@ const isLinkAllowed = (
if (link.experimentalKey && !experimentalFeatures[link.experimentalKey]) {
return false;
}
if (link.capabilities && !hasCapabilities(link.capabilities, capabilities)) {
return false;
}
return true;
};

View file

@ -10,6 +10,8 @@ import type { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types';
import type { IconType } from '@elastic/eui';
import type { ExperimentalFeatures } from '../../../common/experimental_features';
import type { SecurityPageName } from '../../../common/constants';
import type { UpsellingService } from '../lib/upsellings';
import type { RequiredCapabilities } from '../lib/capabilities';
/**
* Permissions related parameters needed for the links to be filtered
@ -17,6 +19,7 @@ import type { SecurityPageName } from '../../../common/constants';
export interface LinksPermissions {
capabilities: Capabilities;
experimentalFeatures: Readonly<ExperimentalFeatures>;
upselling: UpsellingService;
license?: ILicense;
}
@ -41,7 +44,7 @@ export interface LinkItem {
* The final format is to specify a single feature, this would be like: features: feature1, which is the same as
* features: [feature1]
*/
capabilities?: string | Array<string | string[]>;
capabilities?: RequiredCapabilities;
/**
* Categories to display in the navigation
*/
@ -124,6 +127,10 @@ export interface LinkItem {
* Title of the link
*/
title: string;
/**
* Reserved for links management, this property is set automatically
* */
unauthorized?: boolean;
}
export type AppLinkItems = Readonly<LinkItem[]>;
@ -142,6 +149,7 @@ export interface NavigationLink {
image?: string;
title: string;
skipUrlState?: boolean;
unauthorized?: boolean;
isBeta?: boolean;
betaOptions?: {
text: string;

View file

@ -7,6 +7,7 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { allowedExperimentalValues } from '../../../../common/experimental_features';
import { UpsellingService } from '../../lib/upsellings';
import { updateAppLinks } from '../../links';
import { links } from '../../links/app_links';
import { useShowTimeline } from './use_show_timeline';
@ -51,6 +52,8 @@ jest.mock('../../lib/kibana', () => {
};
});
const mockUpselling = new UpsellingService();
describe('use show timeline', () => {
beforeAll(() => {
// initialize all App links before running test
@ -66,6 +69,7 @@ describe('use show timeline', () => {
crud: true,
},
},
upselling: mockUpselling,
});
});

View file

@ -32,6 +32,7 @@ import type { UsersComponentsQueryProps } from '../../../users/pages/navigation/
import type { HostsComponentsQueryProps } from '../../../hosts/pages/navigation/types';
import { useDashboardHref } from '../../../../common/hooks/use_dashboard_href';
import { RiskScoresNoDataDetected } from '../risk_score_onboarding/risk_score_no_data_detected';
import { useUpsellingComponent } from '../../../../common/hooks/use_upselling';
const StyledEuiFlexGroup = styled(EuiFlexGroup)`
margin-top: ${({ theme }) => theme.eui.euiSizeL};
@ -48,6 +49,7 @@ const RiskDetailsTabBodyComponent: React.FC<
riskEntity: RiskScoreEntity;
}
> = ({ entityName, startDate, endDate, setQuery, deleteQuery, riskEntity }) => {
const RiskScoreUpsell = useUpsellingComponent('entity_analytics_panel');
const queryId = useMemo(
() =>
riskEntity === RiskScoreEntity.host
@ -128,6 +130,10 @@ const RiskDetailsTabBodyComponent: React.FC<
isDeprecated: isDeprecated && !loading,
};
if (RiskScoreUpsell) {
return <RiskScoreUpsell />;
}
if (status.isDisabled || status.isDeprecated) {
return (
<EnableRiskScore

View file

@ -22,6 +22,7 @@ import {
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { EMPTY_SEVERITY_COUNT, RiskScoreEntity } from '../../../../../common/search_strategy';
import { RiskScoresNoDataDetected } from '../../../components/risk_score/risk_score_onboarding/risk_score_no_data_detected';
import { useUpsellingComponent } from '../../../../common/hooks/use_upselling';
const HostRiskScoreTableManage = manageQuery(HostRiskScoreTable);
@ -34,6 +35,7 @@ export const HostRiskScoreQueryTabBody = ({
startDate: from,
type,
}: HostsComponentsQueryProps) => {
const RiskScoreUpsell = useUpsellingComponent('entity_analytics_panel');
const getHostRiskScoreSelector = useMemo(() => hostsSelectors.hostRiskScoreSelector(), []);
const { activePage, limit, sort } = useDeepEqualSelector((state: State) =>
getHostRiskScoreSelector(state, hostsModel.HostsType.page)
@ -90,6 +92,10 @@ export const HostRiskScoreQueryTabBody = ({
isDeprecated: isDeprecated && !loading,
};
if (RiskScoreUpsell) {
return <RiskScoreUpsell />;
}
if (status.isDisabled || status.isDeprecated) {
return (
<EnableRiskScore

View file

@ -24,6 +24,7 @@ import {
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { EMPTY_SEVERITY_COUNT, RiskScoreEntity } from '../../../../../common/search_strategy';
import { RiskScoresNoDataDetected } from '../../../components/risk_score/risk_score_onboarding/risk_score_no_data_detected';
import { useUpsellingComponent } from '../../../../common/hooks/use_upselling';
const UserRiskScoreTableManage = manageQuery(UserRiskScoreTable);
@ -36,6 +37,8 @@ export const UserRiskScoreQueryTabBody = ({
startDate: from,
type,
}: UsersComponentsQueryProps) => {
const RiskScoreUpsell = useUpsellingComponent('entity_analytics_panel');
const getUserRiskScoreSelector = useMemo(() => usersSelectors.userRiskScoreSelector(), []);
const { activePage, limit, sort } = useDeepEqualSelector((state: State) =>
getUserRiskScoreSelector(state)
@ -92,6 +95,10 @@ export const UserRiskScoreQueryTabBody = ({
isDeprecated: isDeprecated && !loading,
};
if (RiskScoreUpsell) {
return <RiskScoreUpsell />;
}
if (status.isDisabled || status.isDeprecated) {
return (
<EnableRiskScore

View file

@ -23,6 +23,9 @@ import {
noCasesCapabilities,
readCasesCapabilities,
} from './cases_test_utils';
import { createStartServicesMock } from './common/lib/kibana/kibana_react.mock';
const mockServices = createStartServicesMock();
describe('public helpers parseRoute', () => {
it('should properly parse hash route', () => {
@ -74,15 +77,20 @@ describe('public helpers parseRoute', () => {
describe('#getSubPluginRoutesByCapabilities', () => {
const mockRender = () => null;
const mockSubPlugins = {
alerts: { routes: [{ path: 'alerts', render: mockRender }] },
cases: { routes: [{ path: 'cases', render: mockRender }] },
} as unknown as StartedSubPlugins;
it('cases routes should return NoPrivilegesPage component when cases plugin is NOT available ', () => {
const routes = getSubPluginRoutesByCapabilities(mockSubPlugins, {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities);
const routes = getSubPluginRoutesByCapabilities(
mockSubPlugins,
{
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities,
mockServices
);
const casesRoute = routes.find((r) => r.path === 'cases');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CasesView = (casesRoute?.component ?? mockRender) as React.ComponentType<any>;
@ -95,10 +103,14 @@ describe('#getSubPluginRoutesByCapabilities', () => {
});
it('alerts should return NoPrivilegesPage component when siem plugin is NOT available ', () => {
const routes = getSubPluginRoutesByCapabilities(mockSubPlugins, {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities);
const routes = getSubPluginRoutesByCapabilities(
mockSubPlugins,
{
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities,
mockServices
);
const alertsRoute = routes.find((r) => r.path === 'alerts');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AlertsView = (alertsRoute?.component ?? mockRender) as React.ComponentType<any>;
@ -111,10 +123,14 @@ describe('#getSubPluginRoutesByCapabilities', () => {
});
it('should return NoPrivilegesPage for each route when both plugins are NOT available ', () => {
const routes = getSubPluginRoutesByCapabilities(mockSubPlugins, {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities);
const routes = getSubPluginRoutesByCapabilities(
mockSubPlugins,
{
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities,
mockServices
);
const casesRoute = routes.find((r) => r.path === 'cases');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CasesView = (casesRoute?.component ?? mockRender) as React.ComponentType<any>;
@ -196,11 +212,11 @@ describe('#isSubPluginAvailable', () => {
describe('RedirectRoute', () => {
it('RedirectRoute should redirect to overview page when siem and case privileges are all', () => {
const mockCapabilitities = {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: true },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/get_started"
/>
@ -208,11 +224,11 @@ describe('RedirectRoute', () => {
});
it('RedirectRoute should redirect to overview page when siem and case privileges are read', () => {
const mockCapabilitities = {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/get_started"
/>
@ -220,11 +236,11 @@ describe('RedirectRoute', () => {
});
it('RedirectRoute should redirect to overview page when siem and case privileges are off', () => {
const mockCapabilitities = {
const mockCapabilities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/get_started"
/>
@ -232,11 +248,11 @@ describe('RedirectRoute', () => {
});
it('RedirectRoute should redirect to overview page when siem privilege is read and case privilege is all', () => {
const mockCapabilitities = {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/get_started"
/>
@ -244,11 +260,11 @@ describe('RedirectRoute', () => {
});
it('RedirectRoute should redirect to overview page when siem privilege is read and case privilege is read', () => {
const mockCapabilitities = {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/get_started"
/>
@ -256,11 +272,11 @@ describe('RedirectRoute', () => {
});
it('RedirectRoute should redirect to cases page when siem privilege is none and case privilege is read', () => {
const mockCapabilitities = {
const mockCapabilities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/cases"
/>
@ -268,11 +284,11 @@ describe('RedirectRoute', () => {
});
it('RedirectRoute should redirect to cases page when siem privilege is none and case privilege is all', () => {
const mockCapabilitities = {
const mockCapabilities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/cases"
/>

View file

@ -33,7 +33,7 @@ import type {
import type { TimelineEqlResponse } from '../common/search_strategy/timeline';
import { NoPrivilegesPage } from './common/components/no_privileges';
import { SecurityPageName } from './app/types';
import type { InspectResponse, StartedSubPlugins } from './types';
import type { InspectResponse, StartedSubPlugins, StartServices } from './types';
import { CASES_SUB_PLUGIN_KEY } from './types';
import { timelineActions } from './timelines/store/timeline';
import { TimelineId } from '../common/types';
@ -194,7 +194,8 @@ export const isThreatIntelligencePath = (pathname: string): boolean => {
export const getSubPluginRoutesByCapabilities = (
subPlugins: StartedSubPlugins,
capabilities: Capabilities
capabilities: Capabilities,
services: StartServices
): RouteProps[] => {
return [
...Object.entries(subPlugins).reduce<RouteProps[]>((acc, [key, value]) => {
@ -207,7 +208,13 @@ export const getSubPluginRoutesByCapabilities = (
...acc,
...value.routes.map((route: RouteProps) => ({
path: route.path,
component: () => <NoPrivilegesPage pageName={key} docLinkSelector={docLinkSelector} />,
component: () => {
const Upsell = services.upselling.getPageUpselling(key as SecurityPageName);
if (Upsell) {
return <Upsell />;
}
return <NoPrivilegesPage pageName={key} docLinkSelector={docLinkSelector} />;
},
})),
];
}, []),

View file

@ -9,6 +9,12 @@ import type { PluginInitializerContext } from '@kbn/core/public';
import { Plugin } from './plugin';
import type { PluginSetup, PluginStart } from './types';
export type { TimelineModel } from './timelines/store/timeline/model';
export type {
UpsellingService,
PageUpsellings,
SectionUpsellings,
UpsellingSectionId,
} from './common/lib/upsellings';
export const plugin = (context: PluginInitializerContext): Plugin => new Plugin(context);

View file

@ -85,7 +85,7 @@ export const entityAnalyticsLinks: LinkItem = {
'Entity analytics, anomalies, and threats to narrow down the monitoring surface area.',
}),
path: ENTITY_ANALYTICS_PATH,
capabilities: [`${SERVER_APP_ID}.show`],
capabilities: [`${SERVER_APP_ID}.entity-analytics`],
isBeta: false,
globalSearchKeywords: [ENTITY_ANALYTICS],
};

View file

@ -23,6 +23,7 @@ import { DataQuality } from './pages/data_quality';
import { DetectionResponse } from './pages/detection_response';
import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper';
import { EntityAnalyticsPage } from './pages/entity_analytics';
import { SecurityRoutePageWrapper } from '../common/components/security_route_page_wrapper';
const OverviewRoutes = () => (
<PluginTemplateWrapper>
@ -48,9 +49,9 @@ const LandingRoutes = () => (
const EntityAnalyticsRoutes = () => (
<PluginTemplateWrapper>
<TrackApplicationView viewId={SecurityPageName.entityAnalytics}>
<SecurityRoutePageWrapper pageName={SecurityPageName.entityAnalytics}>
<EntityAnalyticsPage />
</TrackApplicationView>
</SecurityRoutePageWrapper>
</PluginTemplateWrapper>
);

View file

@ -50,6 +50,7 @@ import { getLazyEndpointPolicyResponseExtension } from './management/pages/polic
import { getLazyEndpointGenericErrorsListExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_generic_errors_list';
import type { ExperimentalFeatures } from '../common/experimental_features';
import { parseExperimentalConfigValue } from '../common/experimental_features';
import { UpsellingService } from './common/lib/upsellings';
import { LazyEndpointCustomAssetsExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_custom_assets_extension';
import type { SecurityAppStore } from './common/store/types';
@ -79,6 +80,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
private telemetry: TelemetryService;
readonly experimentalFeatures: ExperimentalFeatures;
private upsellingService: UpsellingService;
private isSidebarEnabled$: BehaviorSubject<boolean>;
private getStartedComponent?: GetStartedComponent;
@ -89,6 +91,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.kibanaBranch = initializerContext.env.packageInfo.branch;
this.prebuiltRulesPackageVersion = this.config.prebuiltRulesPackageVersion;
this.isSidebarEnabled$ = new BehaviorSubject<boolean>(true);
this.upsellingService = new UpsellingService();
this.telemetry = new TelemetryService();
}
private appUpdater$ = new Subject<AppUpdater>();
@ -165,6 +168,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
savedObjectsManagement: startPluginsDeps.savedObjectsManagement,
isSidebarEnabled$: this.isSidebarEnabled$,
getStartedComponent: this.getStartedComponent,
upselling: this.upsellingService,
telemetry: this.telemetry.start(),
};
return services;
@ -203,7 +207,8 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
usageCollection: plugins.usageCollection,
subPluginRoutes: getSubPluginRoutesByCapabilities(
subPlugins,
coreStart.application.capabilities
coreStart.application.capabilities,
services
),
});
},
@ -239,6 +244,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
);
return resolverPluginSetup();
},
upselling: this.upsellingService,
};
}
@ -491,6 +497,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
license$.subscribe(async (license) => {
const linksPermissions: LinksPermissions = {
experimentalFeatures: this.experimentalFeatures,
upselling: this.upsellingService,
capabilities: core.application.capabilities,
};

View file

@ -69,6 +69,7 @@ import type { NavigationLink } from './common/links';
import type { TelemetryClientStart } from './common/lib/telemetry';
import type { Dashboards } from './dashboards';
import type { UpsellingService } from './common/lib/upsellings';
export interface SetupPlugins {
cloud?: CloudSetup;
@ -135,11 +136,13 @@ export type StartServices = CoreStart &
savedObjectsManagement: SavedObjectsManagementPluginStart;
isSidebarEnabled$: BehaviorSubject<boolean>;
getStartedComponent: GetStartedComponent | undefined;
upselling: UpsellingService;
telemetry: TelemetryClientStart;
};
export interface PluginSetup {
resolver: () => Promise<ResolverPluginSetup>;
upselling: UpsellingService;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions

View file

@ -1,733 +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 { i18n } from '@kbn/i18n';
import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects';
import type { KibanaFeatureConfig, SubFeatureConfig } from '@kbn/features-plugin/common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common';
import {
createUICapabilities as createCasesUICapabilities,
getApiTags as getCasesApiTags,
} from '@kbn/cases-plugin/common';
import { EXCEPTION_LIST_NAMESPACE_AGNOSTIC } from '@kbn/securitysolution-list-constants';
import { APP_ID, CASES_FEATURE_ID, SERVER_APP_ID } from '../common/constants';
import { savedObjectTypes } from './saved_objects';
import type { ConfigType } from './config';
export const getCasesKibanaFeature = (): KibanaFeatureConfig => {
const casesCapabilities = createCasesUICapabilities();
const casesApiTags = getCasesApiTags(APP_ID);
return {
id: CASES_FEATURE_ID,
name: i18n.translate('xpack.securitySolution.featureRegistry.linkSecuritySolutionCaseTitle', {
defaultMessage: 'Cases',
}),
order: 1100,
category: DEFAULT_APP_CATEGORIES.security,
app: [CASES_FEATURE_ID, 'kibana'],
catalogue: [APP_ID],
cases: [APP_ID],
privileges: {
all: {
api: casesApiTags.all,
app: [CASES_FEATURE_ID, 'kibana'],
catalogue: [APP_ID],
cases: {
create: [APP_ID],
read: [APP_ID],
update: [APP_ID],
push: [APP_ID],
},
savedObject: {
all: [...filesSavedObjectTypes],
read: [...filesSavedObjectTypes],
},
ui: casesCapabilities.all,
},
read: {
api: casesApiTags.read,
app: [CASES_FEATURE_ID, 'kibana'],
catalogue: [APP_ID],
cases: {
read: [APP_ID],
},
savedObject: {
all: [],
read: [...filesSavedObjectTypes],
},
ui: casesCapabilities.read,
},
},
subFeatures: [
{
name: i18n.translate('xpack.securitySolution.featureRegistry.deleteSubFeatureName', {
defaultMessage: 'Delete',
}),
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
api: casesApiTags.delete,
id: 'cases_delete',
name: i18n.translate(
'xpack.securitySolution.featureRegistry.deleteSubFeatureDetails',
{
defaultMessage: 'Delete cases and comments',
}
),
includeIn: 'all',
savedObject: {
all: [...filesSavedObjectTypes],
read: [...filesSavedObjectTypes],
},
cases: {
delete: [APP_ID],
},
ui: casesCapabilities.delete,
},
],
},
],
},
],
};
};
// Same as the plugin id defined by Cloud Security Posture
const CLOUD_POSTURE_APP_ID = 'csp';
// Same as the saved-object type for rules defined by Cloud Security Posture
const CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE = 'csp_rule';
const responseActionSubFeatures: SubFeatureConfig[] = [
{
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.responseActionsHistory.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Response Actions History access.',
}
),
name: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.responseActionsHistory',
{
defaultMessage: 'Response Actions History',
}
),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.responseActionsHistory.description',
{
defaultMessage: 'Access the history of response actions performed on endpoints.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeActionsLogManagement`, `${APP_ID}-readActionsLogManagement`],
id: 'actions_log_management_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeActionsLogManagement', 'readActionsLogManagement'],
},
{
api: [`${APP_ID}-readActionsLogManagement`],
id: 'actions_log_management_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readActionsLogManagement'],
},
],
},
],
},
{
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolation.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Host Isolation access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.hostIsolation', {
defaultMessage: 'Host Isolation',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolation.description',
{ defaultMessage: 'Perform the "isolate" and "release" response actions.' }
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeHostIsolation`],
id: 'host_isolation_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeHostIsolation'],
},
],
},
],
},
{
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.processOperations.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Process Operations access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.processOperations', {
defaultMessage: 'Process Operations',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.processOperations.description',
{
defaultMessage: 'Perform process-related response actions in the response console.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeProcessOperations`],
id: 'process_operations_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeProcessOperations'],
},
],
},
],
},
{
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.fileOperations.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for File Operations access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.fileOperations', {
defaultMessage: 'File Operations',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.fileOperations.description',
{
defaultMessage: 'Perform file-related response actions in the response console.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeFileOperations`],
id: 'file_operations_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeFileOperations'],
},
],
},
],
},
];
const subFeatures: SubFeatureConfig[] = [
{
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.endpointList.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Endpoint List access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.endpointList', {
defaultMessage: 'Endpoint List',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.endpointList.description',
{
defaultMessage:
'Displays all hosts running Elastic Defend and their relevant integration details.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeEndpointList`, `${APP_ID}-readEndpointList`],
id: 'endpoint_list_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeEndpointList', 'readEndpointList'],
},
{
api: [`${APP_ID}-readEndpointList`],
id: 'endpoint_list_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readEndpointList'],
},
],
},
],
},
{
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.trustedApplications.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Trusted Applications access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.trustedApplications', {
defaultMessage: 'Trusted Applications',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.trustedApplications.description',
{
defaultMessage:
'Helps mitigate conflicts with other software, usually other antivirus or endpoint security applications.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [
'lists-all',
'lists-read',
'lists-summary',
`${APP_ID}-writeTrustedApplications`,
`${APP_ID}-readTrustedApplications`,
],
id: 'trusted_applications_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC],
read: [],
},
ui: ['writeTrustedApplications', 'readTrustedApplications'],
},
{
api: ['lists-read', 'lists-summary', `${APP_ID}-readTrustedApplications`],
id: 'trusted_applications_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readTrustedApplications'],
},
],
},
],
},
{
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolationExceptions.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Host Isolation Exceptions access.',
}
),
name: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolationExceptions',
{
defaultMessage: 'Host Isolation Exceptions',
}
),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolationExceptions.description',
{
defaultMessage:
'Add specific IP addresses that isolated hosts are still allowed to communicate with, even when isolated from the rest of the network.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [
'lists-all',
'lists-read',
'lists-summary',
`${APP_ID}-writeHostIsolationExceptions`,
`${APP_ID}-readHostIsolationExceptions`,
],
id: 'host_isolation_exceptions_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC],
read: [],
},
ui: ['writeHostIsolationExceptions', 'readHostIsolationExceptions'],
},
{
api: ['lists-read', 'lists-summary', `${APP_ID}-readHostIsolationExceptions`],
id: 'host_isolation_exceptions_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readHostIsolationExceptions'],
},
],
},
],
},
{
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.blockList.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Blocklist access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.blockList', {
defaultMessage: 'Blocklist',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.blockList.description',
{
defaultMessage:
'Extend Elastic Defends protection against malicious processes and protect against potentially harmful applications.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [
'lists-all',
'lists-read',
'lists-summary',
`${APP_ID}-writeBlocklist`,
`${APP_ID}-readBlocklist`,
],
id: 'blocklist_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC],
read: [],
},
ui: ['writeBlocklist', 'readBlocklist'],
},
{
api: ['lists-read', 'lists-summary', `${APP_ID}-readBlocklist`],
id: 'blocklist_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readBlocklist'],
},
],
},
],
},
{
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.eventFilters.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Event Filters access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.eventFilters', {
defaultMessage: 'Event Filters',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.eventFilters.description',
{
defaultMessage:
'Filter out endpoint events that you do not need or want stored in Elasticsearch.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [
'lists-all',
'lists-read',
'lists-summary',
`${APP_ID}-writeEventFilters`,
`${APP_ID}-readEventFilters`,
],
id: 'event_filters_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC],
read: [],
},
ui: ['writeEventFilters', 'readEventFilters'],
},
{
api: ['lists-read', 'lists-summary', `${APP_ID}-readEventFilters`],
id: 'event_filters_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readEventFilters'],
},
],
},
],
},
{
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.policyManagement.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Policy Management access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.policyManagement', {
defaultMessage: 'Elastic Defend Policy Management',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.policyManagement.description',
{
defaultMessage:
'Access the Elastic Defend integration policy to configure protections, event collection, and advanced policy features.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writePolicyManagement`, `${APP_ID}-readPolicyManagement`],
id: 'policy_management_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writePolicyManagement', 'readPolicyManagement'],
},
{
api: [`${APP_ID}-readPolicyManagement`],
id: 'policy_management_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readPolicyManagement'],
},
],
},
],
},
...responseActionSubFeatures,
];
// execute operations are not available in 8.7,
// but will be available in 8.8
const executeActionSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.executeOperations.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Execute Operations access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.executeOperations', {
defaultMessage: 'Execute Operations',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.executeOperations.description',
{
// TODO: Update this description before 8.8 FF
defaultMessage: 'Perform script execution on the endpoint.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeExecuteOperations`],
id: 'execute_operations_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeExecuteOperations'],
},
],
},
],
};
function getSubFeatures(experimentalFeatures: ConfigType['experimentalFeatures']) {
let filteredSubFeatures: SubFeatureConfig[] = [];
if (experimentalFeatures.endpointRbacEnabled) {
filteredSubFeatures = subFeatures;
} else if (experimentalFeatures.endpointRbacV1Enabled) {
filteredSubFeatures = responseActionSubFeatures;
}
if (!experimentalFeatures.responseActionGetFileEnabled) {
filteredSubFeatures = filteredSubFeatures.filter((subFeat) => {
return subFeat.name !== 'File Operations';
});
}
// behind FF (planned for 8.8)
if (experimentalFeatures.responseActionExecuteEnabled) {
filteredSubFeatures = [...filteredSubFeatures, executeActionSubFeature];
}
return filteredSubFeatures;
}
export const getKibanaPrivilegesFeaturePrivileges = (
ruleTypes: string[],
experimentalFeatures: ConfigType['experimentalFeatures']
): KibanaFeatureConfig => ({
id: SERVER_APP_ID,
name: i18n.translate('xpack.securitySolution.featureRegistry.linkSecuritySolutionTitle', {
defaultMessage: 'Security',
}),
order: 1100,
category: DEFAULT_APP_CATEGORIES.security,
app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'],
catalogue: [APP_ID],
management: {
insightsAndAlerting: ['triggersActions'],
},
alerting: ruleTypes,
privileges: {
all: {
app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'],
catalogue: [APP_ID],
api: [
APP_ID,
'lists-all',
'lists-read',
'lists-summary',
'rac',
'cloud-security-posture-all',
'cloud-security-posture-read',
],
savedObject: {
all: [
'alert',
'exception-list',
EXCEPTION_LIST_NAMESPACE_AGNOSTIC,
DATA_VIEW_SAVED_OBJECT_TYPE,
...savedObjectTypes,
CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE,
],
read: [],
},
alerting: {
rule: {
all: ruleTypes,
},
alert: {
all: ruleTypes,
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show', 'crud'],
},
read: {
app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'],
catalogue: [APP_ID],
api: [APP_ID, 'lists-read', 'rac', 'cloud-security-posture-read'],
savedObject: {
all: [],
read: [
'exception-list',
EXCEPTION_LIST_NAMESPACE_AGNOSTIC,
DATA_VIEW_SAVED_OBJECT_TYPE,
...savedObjectTypes,
CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE,
],
},
alerting: {
rule: {
read: ruleTypes,
},
alert: {
all: ruleTypes,
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show'],
},
},
subFeatures: getSubFeatures(experimentalFeatures),
});

View file

@ -0,0 +1,110 @@
/*
* 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 { AppFeatures } from '.';
import type { Logger } from '@kbn/core/server';
import type { AppFeatureKeys, ExperimentalFeatures } from '../../../common';
import type { PluginSetupContract } from '@kbn/features-plugin/server';
const SECURITY_BASE_CONFIG = {
foo: 'foo',
};
const SECURITY_APP_FEATURE_CONFIG = {
'test-base-feature': {
privileges: {
all: {
ui: ['test-capability'],
api: ['test-capability'],
},
read: {
ui: ['test-capability'],
api: ['test-capability'],
},
},
},
};
const CASES_BASE_CONFIG = {
bar: 'bar',
};
const CASES_APP_FEATURE_CONFIG = {
'test-cases-feature': {
privileges: {
all: {
ui: ['test-cases-capability'],
api: ['test-cases-capability'],
},
read: {
ui: ['test-cases-capability'],
api: ['test-cases-capability'],
},
},
},
};
jest.mock('./security_kibana_features', () => {
return {
getSecurityBaseKibanaFeature: jest.fn().mockReturnValue(SECURITY_BASE_CONFIG),
getSecurityAppFeaturesConfig: jest.fn().mockReturnValue(SECURITY_APP_FEATURE_CONFIG),
};
});
jest.mock('./security_cases_kibana_features', () => {
return {
getCasesBaseKibanaFeature: jest.fn().mockReturnValue(CASES_BASE_CONFIG),
getCasesAppFeaturesConfig: jest.fn().mockReturnValue(CASES_APP_FEATURE_CONFIG),
};
});
describe('AppFeatures', () => {
it('should register enabled kibana features', () => {
const featuresSetup = {
registerKibanaFeature: jest.fn(),
getKibanaFeatures: jest.fn(),
} as unknown as PluginSetupContract;
const appFeatureKeys = {
'test-base-feature': true,
} as unknown as AppFeatureKeys;
const appFeatures = new AppFeatures(
{} as unknown as Logger,
[] as unknown as ExperimentalFeatures
);
appFeatures.init(featuresSetup);
appFeatures.set(appFeatureKeys);
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({
...SECURITY_BASE_CONFIG,
...SECURITY_APP_FEATURE_CONFIG['test-base-feature'],
});
});
it('should register enabled cases features', () => {
const featuresSetup = {
registerKibanaFeature: jest.fn(),
} as unknown as PluginSetupContract;
const appFeatureKeys = {
'test-cases-feature': true,
} as unknown as AppFeatureKeys;
const appFeatures = new AppFeatures(
{} as unknown as Logger,
[] as unknown as ExperimentalFeatures
);
appFeatures.init(featuresSetup);
appFeatures.set(appFeatureKeys);
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({
...CASES_BASE_CONFIG,
...CASES_APP_FEATURE_CONFIG['test-cases-feature'],
});
});
});

View file

@ -0,0 +1,99 @@
/*
* 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 { Logger } from '@kbn/core/server';
import type { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server';
import type { AppFeatureKey, AppFeatureKeys, ExperimentalFeatures } from '../../../common';
import type { AppFeatureKibanaConfig, AppFeaturesConfig } from './types';
import {
getSecurityAppFeaturesConfig,
getSecurityBaseKibanaFeature,
} from './security_kibana_features';
import {
getCasesBaseKibanaFeature,
getCasesAppFeaturesConfig,
} from './security_cases_kibana_features';
import { AppFeaturesConfigMerger } from './app_features_config_merger';
type AppFeaturesMap = Map<AppFeatureKey, boolean>;
export class AppFeatures {
private merger: AppFeaturesConfigMerger;
private appFeatures?: AppFeaturesMap;
private featuresSetup?: FeaturesPluginSetup;
constructor(
private readonly logger: Logger,
private readonly experimentalFeatures: ExperimentalFeatures
) {
this.merger = new AppFeaturesConfigMerger(this.logger);
}
public init(featuresSetup: FeaturesPluginSetup) {
this.featuresSetup = featuresSetup;
}
public set(appFeatureKeys: AppFeatureKeys) {
if (this.appFeatures) {
throw new Error('AppFeatures has already been initialized');
}
this.appFeatures = new Map(Object.entries(appFeatureKeys) as Array<[AppFeatureKey, boolean]>);
this.registerEnabledKibanaFeatures();
}
public isEnabled(appFeatureKey: AppFeatureKey): boolean {
if (!this.appFeatures) {
throw new Error('AppFeatures has not been initialized');
}
return this.appFeatures.get(appFeatureKey) ?? false;
}
private registerEnabledKibanaFeatures() {
if (this.featuresSetup == null) {
throw new Error(
'Cannot sync kibana features as featuresSetup is not present. Did you call init?'
);
}
// register main security Kibana features
const securityBaseKibanaFeature = getSecurityBaseKibanaFeature(this.experimentalFeatures);
const enabledSecurityAppFeaturesConfigs = this.getEnabledAppFeaturesConfigs(
getSecurityAppFeaturesConfig()
);
this.featuresSetup.registerKibanaFeature(
this.merger.mergeAppFeatureConfigs(
securityBaseKibanaFeature,
enabledSecurityAppFeaturesConfigs
)
);
// register security cases Kibana features
const securityCasesBaseKibanaFeature = getCasesBaseKibanaFeature();
const enabledCasesAppFeaturesConfigs = this.getEnabledAppFeaturesConfigs(
getCasesAppFeaturesConfig()
);
this.featuresSetup.registerKibanaFeature(
this.merger.mergeAppFeatureConfigs(
securityCasesBaseKibanaFeature,
enabledCasesAppFeaturesConfigs
)
);
}
private getEnabledAppFeaturesConfigs(
appFeaturesConfigs: Partial<AppFeaturesConfig>
): AppFeatureKibanaConfig[] {
return Object.entries(appFeaturesConfigs).reduce<AppFeatureKibanaConfig[]>(
(acc, [appFeatureKey, appFeatureConfig]) => {
if (this.isEnabled(appFeatureKey as AppFeatureKey)) {
acc.push(appFeatureConfig);
}
return acc;
},
[]
);
}
}

View file

@ -0,0 +1,380 @@
/*
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
import { AppFeaturesConfigMerger } from './app_features_config_merger';
import type { Logger } from '@kbn/core/server';
import type { AppFeatureKibanaConfig } from './types';
import type { KibanaFeatureConfig } from '@kbn/features-plugin/common';
const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
describe('AppFeaturesConfigMerger', () => {
// We don't need to update this test when cases config change
// It mocks simplified versions of cases config
it('merges a mocked version of cases config', () => {
const merger = new AppFeaturesConfigMerger(mockLogger);
const category = {
id: 'security',
label: 'Security app category',
};
const securityCasesBaseKibanaFeature: KibanaFeatureConfig = {
id: 'CASES_FEATURE_ID',
name: 'Cases',
order: 1100,
category,
app: ['CASES_FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
privileges: {
all: {
api: [],
app: ['CASES_FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
savedObject: {
all: [],
read: [],
},
ui: [],
},
read: {
api: [],
app: ['CASES_FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
savedObject: {
all: [],
read: [],
},
ui: [],
},
},
};
const enabledCasesAppFeaturesConfigs: AppFeatureKibanaConfig[] = [
{
cases: ['APP_ID'],
privileges: {
all: {
api: ['casesApiTags.all'],
ui: ['casesCapabilities.all'],
cases: {
create: ['APP_ID'],
read: ['APP_ID'],
update: ['APP_ID'],
push: ['APP_ID'],
},
savedObject: {
all: ['filesSavedObjectTypes'],
read: ['filesSavedObjectTypes'],
},
},
read: {
api: ['casesApiTags.read'],
ui: ['casesCapabilities.read'],
cases: {
read: ['APP_ID'],
},
savedObject: {
all: [],
read: ['filesSavedObjectTypes'],
},
},
},
subFeatures: [
{
name: 'Delete',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
api: ['casesApiTags.delete'],
id: 'cases_delete',
name: 'Delete cases and comments',
includeIn: 'all',
savedObject: {
all: ['filesSavedObjectTypes'],
read: ['filesSavedObjectTypes'],
},
cases: {
delete: ['APP_ID'],
},
ui: ['casesCapabilities.delete'],
},
],
},
],
},
],
},
];
const merged = merger.mergeAppFeatureConfigs(
securityCasesBaseKibanaFeature,
enabledCasesAppFeaturesConfigs
);
expect(merged).toEqual({
id: 'CASES_FEATURE_ID',
name: 'Cases',
order: 1100,
category,
app: ['CASES_FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
cases: ['APP_ID'],
privileges: {
all: {
api: ['casesApiTags.all'],
app: ['CASES_FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
cases: {
create: ['APP_ID'],
read: ['APP_ID'],
update: ['APP_ID'],
push: ['APP_ID'],
},
savedObject: {
all: ['filesSavedObjectTypes'],
read: ['filesSavedObjectTypes'],
},
ui: ['casesCapabilities.all'],
},
read: {
api: ['casesApiTags.read'],
app: ['CASES_FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
cases: {
read: ['APP_ID'],
},
savedObject: {
all: [],
read: ['filesSavedObjectTypes'],
},
ui: ['casesCapabilities.read'],
},
},
subFeatures: [
{
name: 'Delete',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
api: ['casesApiTags.delete'],
id: 'cases_delete',
name: 'Delete cases and comments',
includeIn: 'all',
savedObject: {
all: ['filesSavedObjectTypes'],
read: ['filesSavedObjectTypes'],
},
cases: {
delete: ['APP_ID'],
},
ui: ['casesCapabilities.delete'],
},
],
},
],
},
],
});
});
it('merges a mocked version of security basic config', () => {
const merger = new AppFeaturesConfigMerger(mockLogger);
const category = {
id: 'security',
label: 'Security app category',
};
const securityCasesBaseKibanaFeature: KibanaFeatureConfig = {
id: 'SERVER_APP_ID',
name: 'Security',
order: 1100,
category,
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
management: {
insightsAndAlerting: ['triggersActions'],
},
alerting: ['THRESHOLD_RULE_TYPE_ID', 'NEW_TERMS_RULE_TYPE_ID'],
privileges: {
all: {
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
api: ['APP_ID', 'cloud-security-posture-read'],
savedObject: {
all: ['alert', 'CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE'],
read: [],
},
alerting: {
rule: {
all: ['SECURITY_RULE_TYPES'],
},
alert: {
all: ['SECURITY_RULE_TYPES'],
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show', 'crud'],
},
read: {
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
api: ['APP_ID', 'lists-read', 'rac', 'cloud-security-posture-read'],
savedObject: {
all: [],
read: ['CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE'],
},
alerting: {
rule: {
read: ['SECURITY_RULE_TYPES'],
},
alert: {
all: ['SECURITY_RULE_TYPES'],
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show'],
},
},
subFeatures: [
{
requireAllSpaces: true,
privilegesTooltip: 'All Spaces is required for Host Isolation access.',
name: 'Host Isolation',
description: 'Perform the "isolate" and "release" response actions.',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`APP_ID-writeHostIsolation`],
id: 'host_isolation_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeHostIsolation'],
},
],
},
],
},
],
};
const enabledCasesAppFeaturesConfigs: AppFeatureKibanaConfig[] = [
{
privileges: {
all: {
api: ['rules_load_prepackaged'],
ui: ['rules_load_prepackaged'],
},
},
},
];
const merged = merger.mergeAppFeatureConfigs(
securityCasesBaseKibanaFeature,
enabledCasesAppFeaturesConfigs
);
expect(merged).toEqual({
id: 'SERVER_APP_ID',
name: 'Security',
order: 1100,
category,
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
management: {
insightsAndAlerting: ['triggersActions'],
},
alerting: ['THRESHOLD_RULE_TYPE_ID', 'NEW_TERMS_RULE_TYPE_ID'],
privileges: {
all: {
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
api: ['APP_ID', 'cloud-security-posture-read', 'rules_load_prepackaged'],
savedObject: {
all: ['alert', 'CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE'],
read: [],
},
alerting: {
rule: {
all: ['SECURITY_RULE_TYPES'],
},
alert: {
all: ['SECURITY_RULE_TYPES'],
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show', 'crud', 'rules_load_prepackaged'],
},
read: {
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
api: ['APP_ID', 'lists-read', 'rac', 'cloud-security-posture-read'],
savedObject: {
all: [],
read: ['CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE'],
},
alerting: {
rule: {
read: ['SECURITY_RULE_TYPES'],
},
alert: {
all: ['SECURITY_RULE_TYPES'],
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show'],
},
},
subFeatures: [
{
requireAllSpaces: true,
privilegesTooltip: 'All Spaces is required for Host Isolation access.',
name: 'Host Isolation',
description: 'Perform the "isolate" and "release" response actions.',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`APP_ID-writeHostIsolation`],
id: 'host_isolation_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeHostIsolation'],
},
],
},
],
},
],
});
});
});

View file

@ -0,0 +1,90 @@
/*
* 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 { cloneDeep, mergeWith, isArray, uniq } from 'lodash';
import type { Logger } from '@kbn/core/server';
import type { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import type { AppFeatureKibanaConfig, SubFeaturesPrivileges } from './types';
export class AppFeaturesConfigMerger {
constructor(private readonly logger: Logger) {}
/**
* Merges `appFeaturesConfigs` into `kibanaFeatureConfig`.
* @param kibanaFeatureConfig the KibanaFeatureConfig to merge into
* @param appFeaturesConfigs the AppFeatureKibanaConfig to merge
* @returns mergedKibanaFeatureConfig the merged KibanaFeatureConfig
* */
public mergeAppFeatureConfigs(
kibanaFeatureConfig: KibanaFeatureConfig,
appFeaturesConfigs: AppFeatureKibanaConfig[]
): KibanaFeatureConfig {
const mergedKibanaFeatureConfig = cloneDeep(kibanaFeatureConfig);
const subFeaturesPrivilegesToMerge: SubFeaturesPrivileges[] = [];
appFeaturesConfigs.forEach((appFeatureConfig) => {
const { subFeaturesPrivileges, ...appFeatureConfigToMerge } = cloneDeep(appFeatureConfig);
if (subFeaturesPrivileges) {
subFeaturesPrivilegesToMerge.push(...subFeaturesPrivileges);
}
mergeWith(mergedKibanaFeatureConfig, appFeatureConfigToMerge, featureConfigMerger);
});
// add subFeaturePrivileges at the end to make sure all enabled subFeatures are merged
subFeaturesPrivilegesToMerge.forEach((subFeaturesPrivileges) => {
this.mergeSubFeaturesPrivileges(mergedKibanaFeatureConfig.subFeatures, subFeaturesPrivileges);
});
return mergedKibanaFeatureConfig;
}
/**
* Merges `subFeaturesPrivileges` into `kibanaFeatureConfig.subFeatures` by finding the subFeature privilege id.
* @param subFeatures the subFeatures to merge into
* @param subFeaturesPrivileges the subFeaturesPrivileges to merge
* @returns void
* */
private mergeSubFeaturesPrivileges(
subFeatures: KibanaFeatureConfig['subFeatures'],
subFeaturesPrivileges: SubFeaturesPrivileges
): void {
if (!subFeatures) {
this.logger.warn('Trying to merge subFeaturesPrivileges but no subFeatures found');
return;
}
const merged = subFeatures.find(({ privilegeGroups }) =>
privilegeGroups.some(({ privileges }) => {
const subFeaturePrivilegeToUpdate = privileges.find(
({ id }) => id === subFeaturesPrivileges.id
);
if (subFeaturePrivilegeToUpdate) {
mergeWith(subFeaturePrivilegeToUpdate, subFeaturesPrivileges, featureConfigMerger);
return true;
}
return false;
})
);
if (!merged) {
this.logger.warn(
`Trying to merge subFeaturesPrivileges ${subFeaturesPrivileges.id} but the subFeature privilege was not found`
);
}
}
}
/**
* The customizer used by lodash.mergeWith to merge deep objects
* Uses concatenation for arrays and removes duplicates, objects are merged using lodash.mergeWith default behavior
* */
function featureConfigMerger(objValue: unknown, srcValue: unknown) {
if (isArray(srcValue)) {
if (isArray(objValue)) {
return uniq(objValue.concat(srcValue));
}
return srcValue;
}
}

View file

@ -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 { AppFeatures } from './app_features';

View file

@ -0,0 +1,73 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects';
import type { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import {
createUICapabilities as createCasesUICapabilities,
getApiTags as getCasesApiTags,
} from '@kbn/cases-plugin/common';
import type { AppFeaturesCasesConfig } from './types';
import { APP_ID, CASES_FEATURE_ID } from '../../../common/constants';
import { casesSubFeatureDelete } from './security_cases_kibana_sub_features';
import { AppFeatureCasesKey } from '../../../common/types/app_features';
const casesCapabilities = createCasesUICapabilities();
const casesApiTags = getCasesApiTags(APP_ID);
export const getCasesBaseKibanaFeature = (): KibanaFeatureConfig => ({
id: CASES_FEATURE_ID,
name: i18n.translate('xpack.securitySolution.featureRegistry.linkSecuritySolutionCaseTitle', {
defaultMessage: 'Cases',
}),
order: 1100,
category: DEFAULT_APP_CATEGORIES.security,
app: [CASES_FEATURE_ID, 'kibana'],
catalogue: [APP_ID],
cases: [APP_ID],
privileges: {
all: {
api: casesApiTags.all,
app: [CASES_FEATURE_ID, 'kibana'],
catalogue: [APP_ID],
cases: {
create: [APP_ID],
read: [APP_ID],
update: [APP_ID],
push: [APP_ID],
},
savedObject: {
all: [...filesSavedObjectTypes],
read: [...filesSavedObjectTypes],
},
ui: casesCapabilities.all,
},
read: {
api: casesApiTags.read,
app: [CASES_FEATURE_ID, 'kibana'],
catalogue: [APP_ID],
cases: {
read: [APP_ID],
},
savedObject: {
all: [],
read: [...filesSavedObjectTypes],
},
ui: casesCapabilities.read,
},
},
subFeatures: [casesSubFeatureDelete],
});
// It maps the AppFeatures keys to Kibana privileges
export const getCasesAppFeaturesConfig = (): AppFeaturesCasesConfig => ({
// TODO Add cases connector configuration
[AppFeatureCasesKey.casesConnectors]: {},
});

View file

@ -0,0 +1,47 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects';
import type { SubFeatureConfig } from '@kbn/features-plugin/common';
import {
createUICapabilities as createCasesUICapabilities,
getApiTags as getCasesApiTags,
} from '@kbn/cases-plugin/common';
import { APP_ID } from '../../../common/constants';
const casesCapabilities = createCasesUICapabilities();
const casesApiTags = getCasesApiTags(APP_ID);
export const casesSubFeatureDelete: SubFeatureConfig = {
name: i18n.translate('xpack.securitySolution.featureRegistry.deleteSubFeatureName', {
defaultMessage: 'Delete',
}),
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
api: casesApiTags.delete,
id: 'cases_delete',
name: i18n.translate('xpack.securitySolution.featureRegistry.deleteSubFeatureDetails', {
defaultMessage: 'Delete cases and comments',
}),
includeIn: 'all',
savedObject: {
all: [...filesSavedObjectTypes],
read: [...filesSavedObjectTypes],
},
cases: {
delete: [APP_ID],
},
ui: casesCapabilities.delete,
},
],
},
],
};

View file

@ -0,0 +1,189 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { KibanaFeatureConfig, SubFeatureConfig } from '@kbn/features-plugin/common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common';
import { EXCEPTION_LIST_NAMESPACE_AGNOSTIC } from '@kbn/securitysolution-list-constants';
import {
EQL_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 { APP_ID, LEGACY_NOTIFICATIONS_ID, SERVER_APP_ID } from '../../../common/constants';
import { savedObjectTypes } from '../../saved_objects';
import type { ExperimentalFeatures } from '../../../common/experimental_features';
import {
blocklistSubFeature,
endpointListSubFeature,
eventFiltersSubFeature,
executeActionSubFeature,
fileOperationsSubFeature,
hostIsolationExceptionsSubFeature,
hostIsolationSubFeature,
policyManagementSubFeature,
processOperationsSubFeature,
responseActionsHistorySubFeature,
trustedApplicationsSubFeature,
} from './security_kibana_sub_features';
import type { AppFeaturesSecurityConfig } from './types';
import { AppFeatureSecurityKey } from '../../../common/types/app_features';
// Same as the plugin id defined by Cloud Security Posture
const CLOUD_POSTURE_APP_ID = 'csp';
// Same as the saved-object type for rules defined by Cloud Security Posture
const CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE = 'csp_rule';
const SECURITY_RULE_TYPES = [
LEGACY_NOTIFICATIONS_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,
];
export const getSecurityBaseKibanaFeature = (
experimentalFeatures: ExperimentalFeatures
): KibanaFeatureConfig => ({
id: SERVER_APP_ID,
name: i18n.translate('xpack.securitySolution.featureRegistry.linkSecuritySolutionTitle', {
defaultMessage: 'Security',
}),
order: 1100,
category: DEFAULT_APP_CATEGORIES.security,
app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'],
catalogue: [APP_ID],
management: {
insightsAndAlerting: ['triggersActions'],
},
alerting: SECURITY_RULE_TYPES,
privileges: {
all: {
app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'],
catalogue: [APP_ID],
api: [
APP_ID,
'lists-all',
'lists-read',
'lists-summary',
'rac',
'cloud-security-posture-all',
'cloud-security-posture-read',
],
savedObject: {
all: [
'alert',
'exception-list',
EXCEPTION_LIST_NAMESPACE_AGNOSTIC,
DATA_VIEW_SAVED_OBJECT_TYPE,
...savedObjectTypes,
CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE,
],
read: [],
},
alerting: {
rule: {
all: SECURITY_RULE_TYPES,
},
alert: {
all: SECURITY_RULE_TYPES,
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show', 'crud'],
},
read: {
app: [APP_ID, CLOUD_POSTURE_APP_ID, 'kibana'],
catalogue: [APP_ID],
api: [APP_ID, 'lists-read', 'rac', 'cloud-security-posture-read'],
savedObject: {
all: [],
read: [
'exception-list',
EXCEPTION_LIST_NAMESPACE_AGNOSTIC,
DATA_VIEW_SAVED_OBJECT_TYPE,
...savedObjectTypes,
CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE,
],
},
alerting: {
rule: {
read: SECURITY_RULE_TYPES,
},
alert: {
all: SECURITY_RULE_TYPES,
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show'],
},
},
subFeatures: getSubFeatures(experimentalFeatures),
});
function getSubFeatures(experimentalFeatures: ExperimentalFeatures) {
const subFeatures: SubFeatureConfig[] = [];
if (experimentalFeatures.endpointRbacEnabled) {
subFeatures.push(
endpointListSubFeature,
trustedApplicationsSubFeature,
hostIsolationExceptionsSubFeature,
blocklistSubFeature,
eventFiltersSubFeature,
policyManagementSubFeature
);
}
if (experimentalFeatures.endpointRbacEnabled || experimentalFeatures.endpointRbacV1Enabled) {
subFeatures.push(
responseActionsHistorySubFeature,
hostIsolationSubFeature,
processOperationsSubFeature
);
}
if (experimentalFeatures.responseActionGetFileEnabled) {
subFeatures.push(fileOperationsSubFeature);
}
// planned for 8.8
if (experimentalFeatures.responseActionExecuteEnabled) {
subFeatures.push(executeActionSubFeature);
}
return subFeatures;
}
// maps the AppFeatures keys to Kibana privileges
export const getSecurityAppFeaturesConfig = (): AppFeaturesSecurityConfig => {
return {
[AppFeatureSecurityKey.advancedInsights]: {
privileges: {
all: {
ui: ['entity-analytics'],
api: [`${APP_ID}-entity-analytics`],
},
read: {
ui: ['entity-analytics'],
api: [`${APP_ID}-entity-analytics`],
},
},
},
};
};

View file

@ -0,0 +1,525 @@
/*
* 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 { i18n } from '@kbn/i18n';
import type { SubFeatureConfig } from '@kbn/features-plugin/common';
import { EXCEPTION_LIST_NAMESPACE_AGNOSTIC } from '@kbn/securitysolution-list-constants';
import { APP_ID } from '../../../common';
export const endpointListSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.endpointList.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Endpoint List access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.endpointList', {
defaultMessage: 'Endpoint List',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.endpointList.description',
{
defaultMessage:
'Displays all hosts running Elastic Defend and their relevant integration details.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeEndpointList`, `${APP_ID}-readEndpointList`],
id: 'endpoint_list_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeEndpointList', 'readEndpointList'],
},
{
api: [`${APP_ID}-readEndpointList`],
id: 'endpoint_list_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readEndpointList'],
},
],
},
],
};
export const trustedApplicationsSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.trustedApplications.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Trusted Applications access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.trustedApplications', {
defaultMessage: 'Trusted Applications',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.trustedApplications.description',
{
defaultMessage:
'Helps mitigate conflicts with other software, usually other antivirus or endpoint security applications.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [
'lists-all',
'lists-read',
'lists-summary',
`${APP_ID}-writeTrustedApplications`,
`${APP_ID}-readTrustedApplications`,
],
id: 'trusted_applications_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC],
read: [],
},
ui: ['writeTrustedApplications', 'readTrustedApplications'],
},
{
api: ['lists-read', 'lists-summary', `${APP_ID}-readTrustedApplications`],
id: 'trusted_applications_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readTrustedApplications'],
},
],
},
],
};
export const hostIsolationExceptionsSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolationExceptions.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Host Isolation Exceptions access.',
}
),
name: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolationExceptions',
{
defaultMessage: 'Host Isolation Exceptions',
}
),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolationExceptions.description',
{
defaultMessage:
'Add specific IP addresses that isolated hosts are still allowed to communicate with, even when isolated from the rest of the network.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [
'lists-all',
'lists-read',
'lists-summary',
`${APP_ID}-writeHostIsolationExceptions`,
`${APP_ID}-readHostIsolationExceptions`,
],
id: 'host_isolation_exceptions_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC],
read: [],
},
ui: ['writeHostIsolationExceptions', 'readHostIsolationExceptions'],
},
{
api: ['lists-read', 'lists-summary', `${APP_ID}-readHostIsolationExceptions`],
id: 'host_isolation_exceptions_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readHostIsolationExceptions'],
},
],
},
],
};
export const blocklistSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.blockList.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Blocklist access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.blockList', {
defaultMessage: 'Blocklist',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.blockList.description',
{
defaultMessage:
'Extend Elastic Defends protection against malicious processes and protect against potentially harmful applications.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [
'lists-all',
'lists-read',
'lists-summary',
`${APP_ID}-writeBlocklist`,
`${APP_ID}-readBlocklist`,
],
id: 'blocklist_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC],
read: [],
},
ui: ['writeBlocklist', 'readBlocklist'],
},
{
api: ['lists-read', 'lists-summary', `${APP_ID}-readBlocklist`],
id: 'blocklist_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readBlocklist'],
},
],
},
],
};
export const eventFiltersSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.eventFilters.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Event Filters access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.eventFilters', {
defaultMessage: 'Event Filters',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.eventFilters.description',
{
defaultMessage:
'Filter out endpoint events that you do not need or want stored in Elasticsearch.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [
'lists-all',
'lists-read',
'lists-summary',
`${APP_ID}-writeEventFilters`,
`${APP_ID}-readEventFilters`,
],
id: 'event_filters_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC],
read: [],
},
ui: ['writeEventFilters', 'readEventFilters'],
},
{
api: ['lists-read', 'lists-summary', `${APP_ID}-readEventFilters`],
id: 'event_filters_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readEventFilters'],
},
],
},
],
};
export const policyManagementSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.policyManagement.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Policy Management access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.policyManagement', {
defaultMessage: 'Elastic Defend Policy Management',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.policyManagement.description',
{
defaultMessage:
'Access the Elastic Defend integration policy to configure protections, event collection, and advanced policy features.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writePolicyManagement`, `${APP_ID}-readPolicyManagement`],
id: 'policy_management_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writePolicyManagement', 'readPolicyManagement'],
},
{
api: [`${APP_ID}-readPolicyManagement`],
id: 'policy_management_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readPolicyManagement'],
},
],
},
],
};
export const responseActionsHistorySubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.responseActionsHistory.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Response Actions History access.',
}
),
name: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.responseActionsHistory',
{
defaultMessage: 'Response Actions History',
}
),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.responseActionsHistory.description',
{
defaultMessage: 'Access the history of response actions performed on endpoints.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeActionsLogManagement`, `${APP_ID}-readActionsLogManagement`],
id: 'actions_log_management_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeActionsLogManagement', 'readActionsLogManagement'],
},
{
api: [`${APP_ID}-readActionsLogManagement`],
id: 'actions_log_management_read',
includeIn: 'none',
name: 'Read',
savedObject: {
all: [],
read: [],
},
ui: ['readActionsLogManagement'],
},
],
},
],
};
export const hostIsolationSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolation.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Host Isolation access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.hostIsolation', {
defaultMessage: 'Host Isolation',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolation.description',
{ defaultMessage: 'Perform the "isolate" and "release" response actions.' }
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeHostIsolation`],
id: 'host_isolation_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeHostIsolation'],
},
],
},
],
};
export const processOperationsSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.processOperations.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Process Operations access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.processOperations', {
defaultMessage: 'Process Operations',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.processOperations.description',
{
defaultMessage: 'Perform process-related response actions in the response console.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeProcessOperations`],
id: 'process_operations_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeProcessOperations'],
},
],
},
],
};
export const fileOperationsSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.fileOperations.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for File Operations access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.fileOperations', {
defaultMessage: 'File Operations',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.fileOperations.description',
{
defaultMessage: 'Perform file-related response actions in the response console.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeFileOperations`],
id: 'file_operations_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeFileOperations'],
},
],
},
],
};
// execute operations are not available in 8.7,
// but will be available in 8.8
export const executeActionSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.executeOperations.privilegesTooltip',
{
defaultMessage: 'All Spaces is required for Execute Operations access.',
}
),
name: i18n.translate('xpack.securitySolution.featureRegistry.subFeatures.executeOperations', {
defaultMessage: 'Execute Operations',
}),
description: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.executeOperations.description',
{
// TODO: Update this description before 8.8 FF
defaultMessage: 'Perform script execution on the endpoint.',
}
),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`${APP_ID}-writeExecuteOperations`],
id: 'execute_operations_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeExecuteOperations'],
},
],
},
],
};

View file

@ -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.
*/
import type { KibanaFeatureConfig, SubFeaturePrivilegeConfig } from '@kbn/features-plugin/common';
import type { AppFeatureKey } from '../../../common';
import type { AppFeatureSecurityKey, AppFeatureCasesKey } from '../../../common/types/app_features';
import type { RecursivePartial } from '../../../common/utility_types';
export type SubFeaturesPrivileges = RecursivePartial<SubFeaturePrivilegeConfig>;
export type AppFeatureKibanaConfig = RecursivePartial<KibanaFeatureConfig> & {
subFeaturesPrivileges?: SubFeaturesPrivileges[];
};
export type AppFeaturesConfig = Record<AppFeatureKey, AppFeatureKibanaConfig>;
export type AppFeaturesSecurityConfig = Record<AppFeatureSecurityKey, AppFeatureKibanaConfig>;
export type AppFeaturesCasesConfig = Record<AppFeatureCasesKey, AppFeatureKibanaConfig>;

View file

@ -7,7 +7,7 @@
import { transformError } from '@kbn/securitysolution-es-utils';
import { RISK_SCORE_INDEX_STATUS_API_URL } from '../../../../common/constants';
import { APP_ID, RISK_SCORE_INDEX_STATUS_API_URL } from '../../../../common/constants';
import type { SecuritySolutionPluginRouter } from '../../../types';
import { buildRouteValidation } from '../../../utils/build_validation/route_validation';
import { buildSiemResponse } from '../../detection_engine/routes/utils';
@ -21,7 +21,7 @@ export const getRiskScoreIndexStatusRoute = (router: SecuritySolutionPluginRoute
query: buildRouteValidation(indexStatusSchema),
},
options: {
tags: ['access:securitySolution'],
tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`],
},
},
async (context, request, response) => {

View file

@ -8,7 +8,7 @@
import { transformError } from '@kbn/securitysolution-es-utils';
import type { Logger } from '@kbn/core/server';
import { INTERNAL_RISK_SCORE_URL } from '../../../../../common/constants';
import { APP_ID, INTERNAL_RISK_SCORE_URL } from '../../../../../common/constants';
import type { SecuritySolutionPluginRouter } from '../../../../types';
import type { SetupPlugins } from '../../../../plugin';
@ -28,7 +28,7 @@ export const installRiskScoresRoute = (
path: INTERNAL_RISK_SCORE_URL,
validate: onboardingRiskScoreSchema,
options: {
tags: ['access:securitySolution'],
tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`],
},
},
async (context, request, response) => {

View file

@ -7,20 +7,10 @@
import type { Observable } from 'rxjs';
import LRU from 'lru-cache';
import {
QUERY_RULE_TYPE_ID,
INDICATOR_RULE_TYPE_ID,
ML_RULE_TYPE_ID,
EQL_RULE_TYPE_ID,
SAVED_QUERY_RULE_TYPE_ID,
THRESHOLD_RULE_TYPE_ID,
NEW_TERMS_RULE_TYPE_ID,
} from '@kbn/securitysolution-rules';
import { QUERY_RULE_TYPE_ID, SAVED_QUERY_RULE_TYPE_ID } from '@kbn/securitysolution-rules';
import type { Logger } from '@kbn/core/server';
import { SavedObjectsClient } from '@kbn/core/server';
import type { UsageCounter } from '@kbn/usage-collection-plugin/server';
import { ECS_COMPONENT_TEMPLATE_NAME } from '@kbn/alerting-plugin/server';
import { mappingFromFieldMap } from '@kbn/alerting-plugin/common';
import type { IRuleDataClient } from '@kbn/rule-registry-plugin/server';
@ -47,12 +37,7 @@ import { AppClientFactory } from './client';
import type { ConfigType } from './config';
import { createConfig } from './config';
import { initUiSettings } from './ui_settings';
import {
APP_ID,
SERVER_APP_ID,
LEGACY_NOTIFICATIONS_ID,
DEFAULT_ALERTS_INDEX,
} from '../common/constants';
import { APP_ID, SERVER_APP_ID, DEFAULT_ALERTS_INDEX } from '../common/constants';
import { registerEndpointRoutes } from './endpoint/routes/metadata';
import { registerPolicyRoutes } from './endpoint/routes/policy';
import { registerActionRoutes } from './endpoint/routes/actions';
@ -71,7 +56,6 @@ import { licenseService } from './lib/license';
import { PolicyWatcher } from './endpoint/lib/policy/license_watch';
import previewPolicy from './lib/detection_engine/routes/index/preview_policy.json';
import { createRuleExecutionLogService } from './lib/detection_engine/rule_monitoring';
import { getKibanaPrivilegesFeaturePrivileges, getCasesKibanaFeature } from './features';
import { EndpointMetadataService } from './endpoint/services/metadata';
import type {
CreateRuleOptions,
@ -106,6 +90,7 @@ import { setIsElasticCloudDeployment } from './lib/telemetry/helpers';
import { artifactService } from './lib/telemetry/artifact';
import { endpointFieldsProvider } from './search_strategy/endpoint_fields';
import { ENDPOINT_FIELDS_SEARCH_STRATEGY } from '../common/endpoint/constants';
import { AppFeatures } from './lib/app_features';
export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract';
@ -114,6 +99,7 @@ export class Plugin implements ISecuritySolutionPlugin {
private readonly config: ConfigType;
private readonly logger: Logger;
private readonly appClientFactory: AppClientFactory;
private readonly appFeatures: AppFeatures;
private readonly endpointAppContextService = new EndpointAppContextService();
private readonly telemetryReceiver: ITelemetryReceiver;
@ -136,6 +122,7 @@ export class Plugin implements ISecuritySolutionPlugin {
this.config = serverConfig;
this.logger = context.logger.get();
this.appClientFactory = new AppClientFactory();
this.appFeatures = new AppFeatures(this.logger, this.config.experimentalFeatures);
// Cache up to three artifacts with a max retention of 5 mins each
this.artifactsCache = new LRU<string, Buffer>({ max: 3, maxAge: 1000 * 60 * 5 });
@ -160,11 +147,12 @@ export class Plugin implements ISecuritySolutionPlugin {
): SecuritySolutionPluginSetup {
this.logger.debug('plugin setup');
const { appClientFactory, pluginContext, config, logger } = this;
const { appClientFactory, appFeatures, pluginContext, config, logger } = this;
const experimentalFeatures = config.experimentalFeatures;
initSavedObjects(core.savedObjects);
initUiSettings(core.uiSettings, experimentalFeatures);
appFeatures.init(plugins.features);
const ruleExecutionLogService = createRuleExecutionLogService(config, logger, core, plugins);
ruleExecutionLogService.registerEventLogProvider();
@ -318,22 +306,6 @@ export class Plugin implements ISecuritySolutionPlugin {
plugins.encryptedSavedObjects?.canEncrypt === true
);
const ruleTypes = [
LEGACY_NOTIFICATIONS_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,
];
plugins.features.registerKibanaFeature(
getKibanaPrivilegesFeaturePrivileges(ruleTypes, experimentalFeatures)
);
plugins.features.registerKibanaFeature(getCasesKibanaFeature());
if (plugins.alerting != null) {
const ruleNotificationType = legacyRulesNotificationAlertType({ logger });
@ -408,7 +380,9 @@ export class Plugin implements ISecuritySolutionPlugin {
*/
plugins.guidedOnboarding.registerGuideConfig(siemGuideId, siemGuideConfig);
return {};
return {
setAppFeatures: this.appFeatures.set.bind(this.appFeatures),
};
}
public start(

View file

@ -41,6 +41,7 @@ import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/
import type { SharePluginStart } from '@kbn/share-plugin/server';
import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server';
import type { PluginSetup as UnifiedSearchServerPluginSetup } from '@kbn/unified-search-plugin/server';
import type { AppFeatures } from './lib/app_features/app_features';
export interface SecuritySolutionPluginSetupDependencies {
alerting: AlertingPluginSetup;
@ -81,8 +82,12 @@ export interface SecuritySolutionPluginStartDependencies {
share: SharePluginStart;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SecuritySolutionPluginSetup {}
export interface SecuritySolutionPluginSetup {
/**
* Sets the app features that are available to the Security Solution
*/
setAppFeatures: AppFeatures['set'];
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SecuritySolutionPluginStart {}

View file

@ -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.
*/
import { schema, TypeOf } from '@kbn/config-schema';
export const productLineId = schema.oneOf([
schema.literal('securityEssentials'),
schema.literal('securityComplete'),
]);
export type SecurityProductLineId = TypeOf<typeof productLineId>;
export const productLineIds = schema.arrayOf<SecurityProductLineId>(productLineId, {
defaultValue: ['securityEssentials'],
});
export type SecurityProductLineIds = TypeOf<typeof productLineIds>;

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/x-pack/plugins/serverless_security/common'],
testMatch: ['<rootDir>/x-pack/plugins/serverless_security/common/**/*.test.{js,mjs,ts,tsx}'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/serverless_security/common',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/serverless_security/common/**/*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/common/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/common/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*',
'!<rootDir>/x-pack/plugins/serverless_security/common/*mock*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/common/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/common/*.d.ts',
'!<rootDir>/x-pack/plugins/serverless_security/common/*.config.ts',
'!<rootDir>/x-pack/plugins/serverless_security/common/index.{js,ts,tsx}',
],
};

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AppFeatureKey } from '@kbn/security-solution-plugin/common';
import type { SecurityProductLineId } from '../config';
export const PLI_APP_FEATURES: Record<SecurityProductLineId, readonly AppFeatureKey[]> = {
securityEssentials: [],
securityComplete: [AppFeatureKey.advancedInsights, AppFeatureKey.casesConnectors],
} as const;

View file

@ -0,0 +1,32 @@
/*
* 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 { getProductAppFeatures } from './pli_features';
import * as pliConfig from './pli_config';
describe('getProductAppFeatures', () => {
it('returns the union of all enabled PLIs features', () => {
// @ts-ignore reassigning readonly value for testing
pliConfig.PLI_APP_FEATURES = { securityEssentials: ['foo'], securityComplete: ['baz'] };
expect(getProductAppFeatures(['securityEssentials', 'securityComplete'])).toEqual({
foo: true,
baz: true,
});
});
it('returns a single PLI when only one is enabled', () => {
// @ts-ignore reassigning readonly value for testing
pliConfig.PLI_APP_FEATURES = { securityEssentials: [], securityComplete: ['foo'] };
expect(getProductAppFeatures(['securityEssentials', 'securityComplete'])).toEqual({
foo: true,
});
});
it('returns an empty object if no PLIs are enabled', () => {
expect(getProductAppFeatures([])).toEqual({});
});
});

View file

@ -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 { AppFeatureKeys } from '@kbn/security-solution-plugin/common';
import { SecurityProductLineId } from '../config';
import { PLI_APP_FEATURES } from './pli_config';
/**
* Returns the U (union) of all enabled PLIs features in a single object.
*/
export const getProductAppFeatures = (productLineIds: SecurityProductLineId[]): AppFeatureKeys =>
productLineIds.reduce<AppFeatureKeys>((appFeatures, productLineId) => {
const productAppFeatures = PLI_APP_FEATURES[productLineId];
productAppFeatures.forEach((featureName) => {
appFeatures[featureName] = true;
});
return appFeatures;
}, {} as AppFeatureKeys);

View file

@ -18,7 +18,9 @@
"securitySolution",
"kibanaReact"
],
"optionalPlugins": [],
"optionalPlugins": [
"essSecurity"
],
"requiredBundles": []
}
}

View file

@ -0,0 +1,7 @@
/*
* 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 { registerUpsellings } from './register_upsellings';

View file

@ -0,0 +1,44 @@
/*
* 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 { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
import { SecurityProductLineIds } from '../../../../common/config';
export const GenericUpsellingPage: React.FC<{ projectPLIs: SecurityProductLineIds }> = React.memo(
({ projectPLIs }) => {
const upsellingPLI = projectPLIs.includes('securityComplete')
? 'Security Complete'
: 'Security Essential';
return (
<EuiEmptyPrompt
iconType="logoSecurity"
title={<>This is a testing component for a Serverless upselling prompt.</>}
body={
<>
Get <EuiLink href="#">{upsellingPLI}</EuiLink> to enable this feature
<br />
<br />
<iframe
title="money"
src="https://giphy.com/embed/px8O7NANzzaqk"
width="480"
height="283"
frameBorder="0"
className="giphy-embed"
allowFullScreen
/>
</>
}
/>
);
}
);
// eslint-disable-next-line import/no-default-export
export { GenericUpsellingPage as default };

View file

@ -0,0 +1,43 @@
/*
* 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 { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
import { SecurityProductLineIds } from '../../../../common/config';
export const GenericUpsellingSection: React.FC<{ projectPLIs: SecurityProductLineIds }> =
React.memo(({ projectPLIs }) => {
const upsellingPLI = projectPLIs.includes('securityComplete')
? 'Security Complete'
: 'Security Essential';
return (
<EuiEmptyPrompt
iconType="logoSecurity"
title={<>This is a testing component for a Serverless upselling prompt.</>}
body={
<>
Get <EuiLink href="#">{upsellingPLI}</EuiLink> to enable this feature
<br />
<br />
<iframe
title="money"
src="https://giphy.com/embed/px8O7NANzzaqk"
width="480"
height="283"
frameBorder="0"
className="giphy-embed"
allowFullScreen
/>
</>
}
/>
);
});
// eslint-disable-next-line import/no-default-export
export { GenericUpsellingSection as default };

View file

@ -0,0 +1,43 @@
/*
* 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 { UpsellingService } from '@kbn/security-solution-plugin/public';
import { registerUpsellings } from './register_upsellings';
const mockGetProductAppFeatures = jest.fn();
jest.mock('../../../common/pli/pli_features', () => ({
getProductAppFeatures: () => mockGetProductAppFeatures(),
}));
describe('registerUpsellings', () => {
it('registers entity analytics upsellings page and section when PLIs features are disabled', () => {
mockGetProductAppFeatures.mockReturnValue({}); // return empty object to simulate no features enabled
const registerPages = jest.fn();
const registerSections = jest.fn();
const upselling = {
registerPages,
registerSections,
} as unknown as UpsellingService;
registerUpsellings(upselling, ['securityEssentials', 'securityComplete']);
expect(registerPages).toHaveBeenCalledTimes(1);
expect(registerPages).toHaveBeenCalledWith(
expect.objectContaining({
['entity-analytics']: expect.any(Function),
})
);
expect(registerSections).toHaveBeenCalledTimes(1);
expect(registerSections).toHaveBeenCalledWith(
expect.objectContaining({
entity_analytics_panel: expect.any(Function),
})
);
});
});

View file

@ -0,0 +1,75 @@
/*
* 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, { lazy } from 'react';
import type { UpsellingService } from '@kbn/security-solution-plugin/public';
import { SecurityPageName, AppFeatureKey } from '@kbn/security-solution-plugin/common';
import type {
PageUpsellings,
SectionUpsellings,
UpsellingSectionId,
} from '@kbn/security-solution-plugin/public';
import type { SecurityProductLineIds } from '../../../common/config';
import { getProductAppFeatures } from '../../../common/pli/pli_features';
const GenericUpsellingPageLazy = lazy(() => import('./pages/generic_upselling_page'));
const GenericUpsellingSectionLazy = lazy(() => import('./pages/generic_upselling_section'));
interface UpsellingsConfig {
feature: AppFeatureKey;
component: React.ComponentType;
}
type UpsellingPages = Array<UpsellingsConfig & { pageName: SecurityPageName }>;
type UpsellingSections = Array<UpsellingsConfig & { id: UpsellingSectionId }>;
export const registerUpsellings = (
upselling: UpsellingService,
projectPLIs: SecurityProductLineIds
) => {
const PLIsFeatures = getProductAppFeatures(projectPLIs);
const upsellingPages = getUpsellingPages(projectPLIs).reduce<PageUpsellings>(
(pageUpsellings, { pageName, feature, component }) => {
if (!PLIsFeatures[feature]) {
pageUpsellings[pageName] = component;
}
return pageUpsellings;
},
{}
);
const upsellingSections = getUpsellingSections(projectPLIs).reduce<SectionUpsellings>(
(sectionUpsellings, { id, feature, component }) => {
if (!PLIsFeatures[feature]) {
sectionUpsellings[id] = component;
}
return sectionUpsellings;
},
{}
);
upselling.registerPages(upsellingPages);
upselling.registerSections(upsellingSections);
};
// Upselling configuration for pages and sections components
const getUpsellingPages = (projectPLIs: SecurityProductLineIds): UpsellingPages => [
{
pageName: SecurityPageName.entityAnalytics,
feature: AppFeatureKey.advancedInsights,
component: () => <GenericUpsellingPageLazy projectPLIs={projectPLIs} />,
},
];
const getUpsellingSections = (projectPLIs: SecurityProductLineIds): UpsellingSections => [
{
id: 'entity_analytics_panel',
feature: AppFeatureKey.advancedInsights,
component: () => <GenericUpsellingSectionLazy projectPLIs={projectPLIs} />,
},
];

View file

@ -5,12 +5,13 @@
* 2.0.
*/
import { PluginInitializerContext } from '@kbn/core/public';
import { ServerlessSecurityPlugin } from './plugin';
// This exports static code and TypeScript types,
// as well as, Kibana Platform `plugin()` initializer.
export function plugin() {
return new ServerlessSecurityPlugin();
export function plugin(initializerContext: PluginInitializerContext) {
return new ServerlessSecurityPlugin(initializerContext);
}
export type { ServerlessSecurityPluginSetup, ServerlessSecurityPluginStart } from './types';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { getSecurityGetStartedComponent } from './components/get_started';
import { getSecuritySideNavComponent } from './components/side_navigation';
import {
@ -13,7 +13,9 @@ import {
ServerlessSecurityPluginStart,
ServerlessSecurityPluginSetupDependencies,
ServerlessSecurityPluginStartDependencies,
ServerlessSecurityPublicConfig,
} from './types';
import { registerUpsellings } from './components/upselling';
export class ServerlessSecurityPlugin
implements
@ -24,10 +26,17 @@ export class ServerlessSecurityPlugin
ServerlessSecurityPluginStartDependencies
>
{
private config: ServerlessSecurityPublicConfig;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<ServerlessSecurityPublicConfig>();
}
public setup(
_core: CoreSetup,
_setupDeps: ServerlessSecurityPluginSetupDependencies
setupDeps: ServerlessSecurityPluginSetupDependencies
): ServerlessSecurityPluginSetup {
registerUpsellings(setupDeps.securitySolution.upselling, this.config.productLineIds);
return {};
}

View file

@ -5,12 +5,13 @@
* 2.0.
*/
import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import {
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import type {
PluginSetup as SecuritySolutionPluginSetup,
PluginStart as SecuritySolutionPluginStart,
} from '@kbn/security-solution-plugin/public';
import { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public';
import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public';
import type { SecurityProductLineIds } from '../common/config';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServerlessSecurityPluginSetup {}
@ -29,3 +30,7 @@ export interface ServerlessSecurityPluginStartDependencies {
securitySolution: SecuritySolutionPluginStart;
serverless: ServerlessPluginStart;
}
export interface ServerlessSecurityPublicConfig {
productLineIds: SecurityProductLineIds;
}

View file

@ -7,17 +7,17 @@
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core/server';
import { productLineIds } from '../common/config';
export * from './types';
const configSchema = schema.object({
export const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
productLineIds,
});
export type ServerlessSecurityConfig = TypeOf<typeof configSchema>;
type ConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<ConfigType> = {
export const config: PluginConfigDescriptor<ServerlessSecurityConfig> = {
exposeToBrowser: {
productLineIds: true,
},
schema: configSchema,
};
export type ServerlessSecurityConfig = TypeOf<typeof configSchema>;

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import { PluginInitializerContext, Plugin } from '@kbn/core/server';
import { PluginInitializerContext, Plugin, CoreSetup } from '@kbn/core/server';
import { ServerlessSecurityConfig } from './config';
import { getProductAppFeatures } from '../common/pli/pli_features';
import {
ServerlessSecurityPluginSetup,
@ -23,9 +25,24 @@ export class ServerlessSecurityPlugin
ServerlessSecurityPluginStartDependencies
>
{
constructor(_initializerContext: PluginInitializerContext) {}
private config: ServerlessSecurityConfig;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<ServerlessSecurityConfig>();
}
public setup(_coreSetup: CoreSetup, pluginsSetup: ServerlessSecurityPluginSetupDependencies) {
// essSecurity plugin should always be disabled when serverlessSecurity is enabled.
// This check is an additional layer of security to prevent double registrations when
// `plugins.forceEnableAllPlugins` flag is enabled).
const shouldRegister = pluginsSetup.essSecurity == null;
if (shouldRegister) {
pluginsSetup.securitySolution.setAppFeatures(
getProductAppFeatures(this.config.productLineIds)
);
}
public setup() {
return {};
}

View file

@ -5,12 +5,15 @@
* 2.0.
*/
import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
import type { PluginSetupContract, PluginStartContract } from '@kbn/features-plugin/server';
import {
PluginSetup as SecuritySolutionPluginSetup,
PluginStart as SecuritySolutionPluginStart,
} from '@kbn/security-solution-plugin/server';
import type { EssSecurityPluginSetup } from '@kbn/ess-security/server';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServerlessSecurityPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@ -19,9 +22,12 @@ export interface ServerlessSecurityPluginStart {}
export interface ServerlessSecurityPluginSetupDependencies {
security: SecurityPluginSetup;
securitySolution: SecuritySolutionPluginSetup;
features: PluginSetupContract;
essSecurity: EssSecurityPluginSetup;
}
export interface ServerlessSecurityPluginStartDependencies {
security: SecurityPluginStart;
securitySolution: SecuritySolutionPluginStart;
features: PluginStartContract;
}

View file

@ -27,5 +27,7 @@
"@kbn/i18n-react",
"@kbn/i18n",
"@kbn/shared-ux-page-kibana-template",
"@kbn/features-plugin",
"@kbn/ess-security",
]
}

View file

@ -4103,6 +4103,10 @@
version "0.0.0"
uid ""
"@kbn/ess-security@link:x-pack/plugins/ess_security":
version "0.0.0"
uid ""
"@kbn/event-annotation-plugin@link:src/plugins/event_annotation":
version "0.0.0"
uid ""