From ecf451f13241a7a71809e3ae92d6989f25d5e06d Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Fri, 18 Oct 2024 11:16:21 -0700 Subject: [PATCH] [8.x] [Spaces] Read Security license to infer eligibility for sub feature customization (#195389) (#196925) # Backport This will backport the following commits from `main` to `8.x`: - [[Spaces] Read Security license to infer eligibility for sub feature customization (#195389)](https://github.com/elastic/kibana/pull/195389) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) ","sha":"e6e30c20215ce7cbb8bd25d6646edc5d0a8bc33e","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Security","release_note:skip","backport missing","v9.0.0","backport:prev-major"],"number":195389,"url":"https://github.com/elastic/kibana/pull/195389","mergeCommit":{"message":"[Spaces] Read Security license to infer eligibility for sub feature customization (#195389)\n\n## Summary\r\n\r\nCloses https://github.com/elastic/kibana/issues/195549\r\n\r\nThis PR adds implementation such that eligibility to allow for the\r\ntoggling of the switch for customization of sub features whilst defining\r\nprivileges that would be assigned to a space is determined from security\r\nlicense.\r\n\r\n\r\n### Before\r\n![ScreenRecording2024-10-09at10 09 33-ezgif\r\ncom-video-to-gif-converter](https://github.com/user-attachments/assets/c80761c9-a45e-4784-835e-e6895d2fbed5)\r\n\r\n### After\r\n\r\n![ScreenRecording2024-10-09at10 05 53-ezgif\r\ncom-video-to-gif-converter](https://github.com/user-attachments/assets/4e7d5724-42b0-4495-8fae-b47e7a97957c)\r\n\r\n","sha":"e6e30c20215ce7cbb8bd25d6646edc5d0a8bc33e"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/195389","number":195389,"mergeCommit":{"message":"[Spaces] Read Security license to infer eligibility for sub feature customization (#195389)\n\n## Summary\r\n\r\nCloses https://github.com/elastic/kibana/issues/195549\r\n\r\nThis PR adds implementation such that eligibility to allow for the\r\ntoggling of the switch for customization of sub features whilst defining\r\nprivileges that would be assigned to a space is determined from security\r\nlicense.\r\n\r\n\r\n### Before\r\n![ScreenRecording2024-10-09at10 09 33-ezgif\r\ncom-video-to-gif-converter](https://github.com/user-attachments/assets/c80761c9-a45e-4784-835e-e6895d2fbed5)\r\n\r\n### After\r\n\r\n![ScreenRecording2024-10-09at10 05 53-ezgif\r\ncom-video-to-gif-converter](https://github.com/user-attachments/assets/4e7d5724-42b0-4495-8fae-b47e7a97957c)\r\n\r\n","sha":"e6e30c20215ce7cbb8bd25d6646edc5d0a8bc33e"}},{"url":"https://github.com/elastic/kibana/pull/196924","number":196924,"branch":"8.16","state":"OPEN"}]}] BACKPORT--> Co-authored-by: Eyo O. Eyo <7893459+eokoneyo@users.noreply.github.com> --- .../security/plugin_types_public/index.ts | 1 + .../plugin_types_public/src/license/index.ts | 10 ++ .../edit_space_content_tab.test.tsx | 8 +- .../edit_space_general_tab.test.tsx | 8 +- .../management/edit_space/edit_space_page.tsx | 8 +- .../edit_space/edit_space_roles_tab.test.tsx | 8 +- .../edit_space/edit_space_roles_tab.tsx | 5 +- .../provider/edit_space_provider.test.tsx | 12 +- .../provider/edit_space_provider.tsx | 73 +++++++-- .../management/edit_space/provider/index.ts | 9 +- .../space_assign_role_privilege_form.test.tsx | 147 ++++++++++++++++-- .../space_assign_role_privilege_form.tsx | 38 ++--- .../management/management_service.test.ts | 4 + .../public/management/management_service.tsx | 26 +--- .../management/security_license.mock.ts | 49 ++++++ .../management/spaces_management_app.test.tsx | 2 + .../management/spaces_management_app.tsx | 6 +- x-pack/plugins/spaces/public/plugin.tsx | 15 +- 18 files changed, 334 insertions(+), 95 deletions(-) create mode 100644 x-pack/packages/security/plugin_types_public/src/license/index.ts create mode 100644 x-pack/plugins/spaces/public/management/security_license.mock.ts diff --git a/x-pack/packages/security/plugin_types_public/index.ts b/x-pack/packages/security/plugin_types_public/index.ts index a48511441382a..fc8829ad8a5f8 100644 --- a/x-pack/packages/security/plugin_types_public/index.ts +++ b/x-pack/packages/security/plugin_types_public/index.ts @@ -24,3 +24,4 @@ export type { } from './src/roles'; export { PrivilegesAPIClientPublicContract } from './src/privileges'; export type { PrivilegesAPIClientGetAllArgs } from './src/privileges'; +export type { SecurityLicense } from './src/license'; diff --git a/x-pack/packages/security/plugin_types_public/src/license/index.ts b/x-pack/packages/security/plugin_types_public/src/license/index.ts new file mode 100644 index 0000000000000..0c1ec0431c10a --- /dev/null +++ b/x-pack/packages/security/plugin_types_public/src/license/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SecurityPluginSetup } from '../plugin'; + +export type SecurityLicense = SecurityPluginSetup['license']; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx index bb55cea5cd50f..f586f0d7f035e 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx @@ -19,12 +19,13 @@ import { import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { EditSpaceContentTab } from './edit_space_content_tab'; -import { EditSpaceProvider } from './provider'; +import { EditSpaceProviderRoot } from './provider'; import type { Space } from '../../../common'; import { spacesManagerMock } from '../../spaces_manager/spaces_manager.mock'; import type { SpaceContentTypeSummaryItem } from '../../types'; import { getPrivilegeAPIClientMock } from '../privilege_api_client.mock'; import { getRolesAPIClientMock } from '../roles_api_client.mock'; +import { getSecurityLicenseMock } from '../security_license.mock'; const getUrlForApp = (appId: string) => appId; const navigateToUrl = jest.fn(); @@ -42,7 +43,7 @@ const logger = loggingSystemMock.createLogger(); const TestComponent: React.FC = ({ children }) => { return ( - = ({ children }) => { notifications={notifications} overlays={overlays} getPrivilegesAPIClient={getPrivilegeAPIClient} + getSecurityLicense={getSecurityLicenseMock} theme={theme} i18n={i18n} logger={logger} > {children} - + ); }; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx index 1c32b97f777c0..9a35572254340 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx @@ -23,12 +23,13 @@ import { KibanaFeature } from '@kbn/features-plugin/common'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { EditSpaceSettingsTab } from './edit_space_general_tab'; -import { EditSpaceProvider } from './provider/edit_space_provider'; +import { EditSpaceProviderRoot } from './provider/edit_space_provider'; import type { SolutionView } from '../../../common'; import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants'; import { spacesManagerMock } from '../../spaces_manager/spaces_manager.mock'; import { getPrivilegeAPIClientMock } from '../privilege_api_client.mock'; import { getRolesAPIClientMock } from '../roles_api_client.mock'; +import { getSecurityLicenseMock } from '../security_license.mock'; const space = { id: 'default', name: 'Default', disabledFeatures: [], _reserved: true }; const history = scopedHistoryMock.create(); @@ -64,7 +65,7 @@ describe('EditSpaceSettings', () => { const TestComponent: React.FC = ({ children }) => { return ( - { notifications={notifications} overlays={overlays} getPrivilegesAPIClient={getPrivilegeAPIClient} + getSecurityLicense={getSecurityLicenseMock} theme={theme} i18n={i18n} logger={logger} > {children} - + ); }; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_page.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_page.tsx index 882301d36459a..bf59f00b5490d 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/edit_space_page.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_page.tsx @@ -9,9 +9,9 @@ import React from 'react'; import type { ComponentProps, PropsWithChildren } from 'react'; import { EditSpace } from './edit_space'; -import { EditSpaceProvider, type EditSpaceProviderProps } from './provider'; +import { EditSpaceProviderRoot, type EditSpaceProviderRootProps } from './provider'; -type EditSpacePageProps = ComponentProps & EditSpaceProviderProps; +type EditSpacePageProps = ComponentProps & EditSpaceProviderRootProps; export function EditSpacePage({ spaceId, @@ -25,7 +25,7 @@ export function EditSpacePage({ ...editSpaceServicesProps }: PropsWithChildren) { return ( - + - + ); } diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx index fccd999eb7941..1959d1d8465ac 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx @@ -19,10 +19,11 @@ import { import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { EditSpaceAssignedRolesTab } from './edit_space_roles_tab'; -import { EditSpaceProvider } from './provider'; +import { EditSpaceProviderRoot } from './provider'; import { spacesManagerMock } from '../../spaces_manager/spaces_manager.mock'; import { getPrivilegeAPIClientMock } from '../privilege_api_client.mock'; import { getRolesAPIClientMock } from '../roles_api_client.mock'; +import { getSecurityLicenseMock } from '../security_license.mock'; const getUrlForApp = (appId: string) => appId; const navigateToUrl = jest.fn(); @@ -51,7 +52,7 @@ describe('EditSpaceAssignedRolesTab', () => { const TestComponent: React.FC = ({ children }) => { return ( - { notifications={notifications} overlays={overlays} getPrivilegesAPIClient={getPrivilegeAPIClient} + getSecurityLicense={getSecurityLicenseMock} theme={theme} i18n={i18n} logger={logger} > {children} - + ); }; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx index 2733790d8de8b..2e3d40527dbd7 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx @@ -62,7 +62,7 @@ export const EditSpaceAssignedRolesTab: FC = ({ space, features, isReadOn (defaultSelected?: Role[]) => { const overlayRef = overlays.openFlyout( toMountPoint( - + = ({ space, features, isReadOn [ overlays, services, + dispatch, + state, space, features, - dispatch, invokeClient, getUrlForApp, theme, diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx index bfd7d7b6059e8..a236b9bc05e1d 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx @@ -20,10 +20,15 @@ import { import type { ApplicationStart } from '@kbn/core-application-browser'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; -import { EditSpaceProvider, useEditSpaceServices, useEditSpaceStore } from './edit_space_provider'; +import { + EditSpaceProviderRoot, + useEditSpaceServices, + useEditSpaceStore, +} from './edit_space_provider'; import { spacesManagerMock } from '../../../spaces_manager/spaces_manager.mock'; import { getPrivilegeAPIClientMock } from '../../privilege_api_client.mock'; import { getRolesAPIClientMock } from '../../roles_api_client.mock'; +import { getSecurityLicenseMock } from '../../security_license.mock'; const http = httpServiceMock.createStartContract(); const notifications = notificationServiceMock.createStartContract(); @@ -45,7 +50,7 @@ const SUTProvider = ({ }: PropsWithChildren>>) => { return ( - _, getRolesAPIClient: getRolesAPIClientMock, getPrivilegesAPIClient: getPrivilegeAPIClientMock, + getSecurityLicense: getSecurityLicenseMock, navigateToUrl: jest.fn(), capabilities, }} > {children} - + ); }; diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx index 75af2beea2108..374d90d19ace1 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx @@ -23,6 +23,7 @@ import type { Logger } from '@kbn/logging'; import type { PrivilegesAPIClientPublicContract, RolesAPIClient, + SecurityLicense, } from '@kbn/security-plugin-types-public'; import { @@ -32,7 +33,7 @@ import { } from './reducers'; import type { SpacesManager } from '../../../spaces_manager'; -export interface EditSpaceProviderProps +export interface EditSpaceProviderRootProps extends Pick { logger: Logger; capabilities: ApplicationStart['capabilities']; @@ -42,10 +43,7 @@ export interface EditSpaceProviderProps spacesManager: SpacesManager; getRolesAPIClient: () => Promise; getPrivilegesAPIClient: () => Promise; -} - -export interface EditSpaceServices extends EditSpaceProviderProps { - invokeClient(arg: (clients: EditSpaceClients) => Promise): Promise; + getSecurityLicense: () => Promise; } interface EditSpaceClients { @@ -54,6 +52,15 @@ interface EditSpaceClients { privilegesClient: PrivilegesAPIClientPublicContract; } +export interface EditSpaceServices + extends Omit< + EditSpaceProviderRootProps, + 'getRolesAPIClient' | 'getPrivilegesAPIClient' | 'getSecurityLicense' + > { + invokeClient(arg: (clients: EditSpaceClients) => Promise): Promise; + license?: SecurityLicense; +} + export interface EditSpaceStore { state: IEditSpaceStoreState; dispatch: Dispatch; @@ -63,16 +70,43 @@ const createSpaceRolesContext = once(() => createContext( const createEditSpaceServicesContext = once(() => createContext(null)); +/** + * + * @description EditSpaceProvider is a provider component that wraps the children components with the necessary context providers for the Edit Space feature. It provides the necessary services and state management for the feature, + * this is provided as an export for use with out of band renders within the spaces app + */ export const EditSpaceProvider = ({ children, + state, + dispatch, ...services -}: PropsWithChildren) => { +}: PropsWithChildren) => { const EditSpaceStoreContext = createSpaceRolesContext(); const EditSpaceServicesContext = createEditSpaceServicesContext(); - const clients = useRef( - Promise.all([services.getRolesAPIClient(), services.getPrivilegesAPIClient()]) + return ( + + + {children} + + ); +}; + +/** + * @description EditSpaceProviderRoot is the root provider for the Edit Space feature. It instantiates the necessary services and state management for the feature. It ideally + * should only be rendered once + */ +export const EditSpaceProviderRoot = ({ + children, + ...services +}: PropsWithChildren) => { + const { logger, getRolesAPIClient, getPrivilegesAPIClient, getSecurityLicense } = services; + + const clients = useRef(Promise.all([getRolesAPIClient(), getPrivilegesAPIClient()])); + const license = useRef(getSecurityLicense); + + const licenseRef = useRef(); const rolesAPIClientRef = useRef(); const privilegesClientRef = useRef(); @@ -81,7 +115,14 @@ export const EditSpaceProvider = ({ fetchRolesError: false, }); - const { logger } = services; + const resolveSecurityLicense = useCallback(async () => { + try { + licenseRef.current = await license.current(); + } catch (err) { + logger.error('Could not resolve Security License!', err); + } + }, [logger]); + const resolveAPIClients = useCallback(async () => { try { [rolesAPIClientRef.current, privilegesClientRef.current] = await clients.current; @@ -94,6 +135,10 @@ export const EditSpaceProvider = ({ resolveAPIClients(); }, [resolveAPIClients]); + useEffect(() => { + resolveSecurityLicense(); + }, [resolveSecurityLicense]); + const createInitialState = useCallback((state: IEditSpaceStoreState) => { return state; }, []); @@ -118,11 +163,11 @@ export const EditSpaceProvider = ({ ); return ( - - - {children} - - + + {children} + ); }; diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/index.ts b/x-pack/plugins/spaces/public/management/edit_space/provider/index.ts index 7ae7301cd2c60..405f59c44a6f8 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/provider/index.ts +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/index.ts @@ -5,9 +5,14 @@ * 2.0. */ -export { EditSpaceProvider, useEditSpaceServices, useEditSpaceStore } from './edit_space_provider'; +export { + EditSpaceProviderRoot, + EditSpaceProvider, + useEditSpaceServices, + useEditSpaceStore, +} from './edit_space_provider'; export type { - EditSpaceProviderProps, + EditSpaceProviderRootProps, EditSpaceServices, EditSpaceStore, } from './edit_space_provider'; diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx index 3595cefd1220c..7f99202e23791 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import crypto from 'crypto'; import React from 'react'; @@ -19,7 +19,7 @@ import { themeServiceMock, } from '@kbn/core/public/mocks'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; -import type { Role } from '@kbn/security-plugin-types-common'; +import type { Role, SecurityLicense } from '@kbn/security-plugin-types-common'; import { createRawKibanaPrivileges, kibanaFeatures, @@ -33,11 +33,8 @@ import { FEATURE_PRIVILEGES_READ, } from '../../../../../common/constants'; import { spacesManagerMock } from '../../../../spaces_manager/spaces_manager.mock'; -import { - createPrivilegeAPIClientMock, - getPrivilegeAPIClientMock, -} from '../../../privilege_api_client.mock'; -import { createRolesAPIClientMock, getRolesAPIClientMock } from '../../../roles_api_client.mock'; +import { createPrivilegeAPIClientMock } from '../../../privilege_api_client.mock'; +import { createRolesAPIClientMock } from '../../../roles_api_client.mock'; import { EditSpaceProvider } from '../../provider'; const rolesAPIClient = createRolesAPIClientMock(); @@ -74,6 +71,9 @@ const spacesClientsInvocatorMock = jest.fn((fn) => const dispatchMock = jest.fn(); const onSaveCompleted = jest.fn(); const closeFlyout = jest.fn(); +const licenseMock = { + getFeatures: jest.fn(() => ({})), +} as unknown as SecurityLicense; const renderPrivilegeRolesForm = ({ preSelectedRoles, @@ -93,15 +93,20 @@ const renderPrivilegeRolesForm = ({ spacesManager, serverBasePath: '', getUrlForApp: jest.fn((_) => _), - getRolesAPIClient: getRolesAPIClientMock, - getPrivilegesAPIClient: getPrivilegeAPIClientMock, navigateToUrl: jest.fn(), + license: licenseMock, capabilities: { navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true }, }, + dispatch: dispatchMock, + state: { + roles: new Map(), + fetchRolesError: false, + }, + invokeClient: spacesClientsInvocatorMock, }} > _), }} /> @@ -358,11 +360,11 @@ describe('PrivilegesRolesForm', () => { preSelectedRoles: roles, }); - await waitFor(() => null); - - expect(screen.getByTestId(`${FEATURE_PRIVILEGES_READ}-privilege-button`)).toHaveAttribute( - 'aria-pressed', - String(true) + await waitFor(() => + expect(screen.getByTestId(`${FEATURE_PRIVILEGES_READ}-privilege-button`)).toHaveAttribute( + 'aria-pressed', + String(true) + ) ); await user.click(screen.getByTestId('custom-privilege-button')); @@ -408,5 +410,116 @@ describe('PrivilegesRolesForm', () => { String(true) ); }); + + it('prevents customization up to sub privilege level by default', async () => { + const user = userEvent.setup(); + + const roles: Role[] = [ + createRole('test_role_1', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + ]; + + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const featuresWithSubFeatures = kibanaFeatures.filter((kibanaFeature) => + Boolean(kibanaFeature.subFeatures.length) + ); + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await user.click(screen.getByTestId('custom-privilege-button')); + + expect( + screen.getByTestId('space-assign-role-privilege-customization-form') + ).toBeInTheDocument(); + + const featureUT = featuresWithSubFeatures[0]; + + // change a single feature with sub features to read from default privilege "none" + await user.click(screen.getByTestId(`${featureUT.id}_${FEATURE_PRIVILEGES_READ}`)); + + // click on the accordion toggle to show sub features + await user.click( + screen.getByTestId( + `featurePrivilegeControls_${featureUT.category.id}_${featureUT.id}_accordionToggle` + ) + ); + + // sub feature table renders + expect( + screen.getByTestId(`${featureUT.category.id}_${featureUT.id}_subFeaturesTable`) + ).toBeInTheDocument(); + + // assert switch to customize sub feature can toggled + expect( + within( + screen.getByTestId( + `${featureUT.category.id}_${featureUT.id}_customizeSubFeaturesSwitchContainer` + ) + ).getByTestId('customizeSubFeaturePrivileges') + ).toBeDisabled(); + }); + + it('supports customization up to sub privilege level only when security license allows', async () => { + const user = userEvent.setup(); + + const roles: Role[] = [ + createRole('test_role_1', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + ]; + + // enable sub feature privileges + (licenseMock.getFeatures as jest.Mock).mockReturnValue({ + allowSubFeaturePrivileges: true, + }); + + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const featuresWithSubFeatures = kibanaFeatures.filter((kibanaFeature) => + Boolean(kibanaFeature.subFeatures.length) + ); + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await user.click(screen.getByTestId('custom-privilege-button')); + + expect( + screen.getByTestId('space-assign-role-privilege-customization-form') + ).toBeInTheDocument(); + + const featureUT = featuresWithSubFeatures[0]; + + // change a single feature with sub features to read from default privilege "none" + await user.click(screen.getByTestId(`${featureUT.id}_${FEATURE_PRIVILEGES_READ}`)); + + // click on the accordion toggle to show sub features + await user.click( + screen.getByTestId( + `featurePrivilegeControls_${featureUT.category.id}_${featureUT.id}_accordionToggle` + ) + ); + + // sub feature table renders + expect( + screen.getByTestId(`${featureUT.category.id}_${featureUT.id}_subFeaturesTable`) + ).toBeInTheDocument(); + + // assert switch to customize sub feature can toggled + expect( + within( + screen.getByTestId( + `${featureUT.category.id}_${featureUT.id}_customizeSubFeaturesSwitchContainer` + ) + ).getByTestId('customizeSubFeaturePrivileges') + ).not.toBeDisabled(); + }); }); }); diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx index f33c2cba25268..e0f3e8f3714c6 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx @@ -46,7 +46,7 @@ import { FEATURE_PRIVILEGES_CUSTOM, FEATURE_PRIVILEGES_READ, } from '../../../../../common/constants'; -import { type EditSpaceServices, type EditSpaceStore, useEditSpaceServices } from '../../provider'; +import { useEditSpaceServices, useEditSpaceStore } from '../../provider'; type KibanaRolePrivilege = | keyof NonNullable @@ -62,9 +62,6 @@ interface PrivilegesRolesFormProps { * this is useful when the form is opened in edit mode */ defaultSelected?: Role[]; - storeDispatch: EditSpaceStore['dispatch']; - spacesClientsInvocator: EditSpaceServices['invokeClient']; - getUrlForApp: EditSpaceServices['getUrlForApp']; } const createRolesComboBoxOptions = (roles: Role[]): Array> => @@ -74,17 +71,9 @@ const createRolesComboBoxOptions = (roles: Role[]): Array = (props) => { - const { - space, - onSaveCompleted, - closeFlyout, - features, - defaultSelected = [], - spacesClientsInvocator, - storeDispatch, - getUrlForApp, - } = props; - const { logger, notifications } = useEditSpaceServices(); + const { space, onSaveCompleted, closeFlyout, features, defaultSelected = [] } = props; + const { logger, notifications, license, invokeClient, getUrlForApp } = useEditSpaceServices(); + const { dispatch: storeDispatch } = useEditSpaceStore(); const [assigningToRole, setAssigningToRole] = useState(false); const [fetchingDataDeps, setFetchingDataDeps] = useState(false); const [kibanaPrivileges, setKibanaPrivileges] = useState(null); @@ -98,7 +87,7 @@ export const PrivilegesRolesForm: FC = (props) => { async function fetchRequiredData(spaceId: string) { setFetchingDataDeps(true); - const [systemRoles, _kibanaPrivileges] = await spacesClientsInvocator((clients) => + const [systemRoles, _kibanaPrivileges] = await invokeClient((clients) => Promise.all([ clients.rolesClient.getRoles(), clients.privilegesClient.getAll({ includeActions: true, respectLicenseLevel: false }), @@ -123,7 +112,7 @@ export const PrivilegesRolesForm: FC = (props) => { } fetchRequiredData(space.id!).finally(() => setFetchingDataDeps(false)); - }, [space.id, spacesClientsInvocator]); + }, [invokeClient, space.id]); const selectedRolesCombinedPrivileges = useMemo(() => { const combinedPrivilege = new Set( @@ -315,7 +304,7 @@ export const PrivilegesRolesForm: FC = (props) => { return selectedRole.value!; }); - await spacesClientsInvocator((clients) => + await invokeClient((clients) => clients.rolesClient.bulkUpdateRoles({ rolesUpdate: updatedRoles }).then((response) => { setAssigningToRole(false); onSaveCompleted(response); @@ -338,13 +327,14 @@ export const PrivilegesRolesForm: FC = (props) => { }); } }, [ + roleSpacePrivilege, + roleCustomizationAnchor.value?.kibana, + roleCustomizationAnchor.privilegeIndex, selectedRoles, - spacesClientsInvocator, + invokeClient, storeDispatch, - onSaveCompleted, space.id, - roleSpacePrivilege, - roleCustomizationAnchor, + onSaveCompleted, logger, notifications.toasts, ]); @@ -571,7 +561,9 @@ export const PrivilegesRolesForm: FC = (props) => { ) } allSpacesSelected={false} - canCustomizeSubFeaturePrivileges={false} + canCustomizeSubFeaturePrivileges={ + license?.getFeatures().allowSubFeaturePrivileges ?? false + } /> )} diff --git a/x-pack/plugins/spaces/public/management/management_service.test.ts b/x-pack/plugins/spaces/public/management/management_service.test.ts index 40a61397e286f..c01c6ecf2e9b3 100644 --- a/x-pack/plugins/spaces/public/management/management_service.test.ts +++ b/x-pack/plugins/spaces/public/management/management_service.test.ts @@ -13,6 +13,7 @@ import { managementPluginMock } from '@kbn/management-plugin/public/mocks'; import { ManagementService } from './management_service'; import { getRolesAPIClientMock } from './roles_api_client.mock'; +import { getSecurityLicenseMock } from './security_license.mock'; import { EventTracker } from '../analytics'; import type { ConfigType } from '../config'; import type { PluginsStart } from '../plugin'; @@ -49,6 +50,7 @@ describe('ManagementService', () => { logger, getRolesAPIClient: getRolesAPIClientMock, getPrivilegesAPIClient: jest.fn(), + getSecurityLicense: getSecurityLicenseMock, eventTracker, }); @@ -72,6 +74,7 @@ describe('ManagementService', () => { logger, getRolesAPIClient: getRolesAPIClientMock, getPrivilegesAPIClient: jest.fn(), + getSecurityLicense: getSecurityLicenseMock, eventTracker, }); }); @@ -96,6 +99,7 @@ describe('ManagementService', () => { logger, getRolesAPIClient: jest.fn(), getPrivilegesAPIClient: jest.fn(), + getSecurityLicense: getSecurityLicenseMock, eventTracker, }); diff --git a/x-pack/plugins/spaces/public/management/management_service.tsx b/x-pack/plugins/spaces/public/management/management_service.tsx index 0379189e192c3..908803728edd6 100644 --- a/x-pack/plugins/spaces/public/management/management_service.tsx +++ b/x-pack/plugins/spaces/public/management/management_service.tsx @@ -5,29 +5,15 @@ * 2.0. */ -import type { StartServicesAccessor } from '@kbn/core/public'; -import type { Logger } from '@kbn/logging'; import type { ManagementApp, ManagementSetup } from '@kbn/management-plugin/public'; -import type { - PrivilegesAPIClientPublicContract, - RolesAPIClient, -} from '@kbn/security-plugin-types-public'; -import { spacesManagementApp } from './spaces_management_app'; -import type { EventTracker } from '../analytics'; -import type { ConfigType } from '../config'; -import type { PluginsStart } from '../plugin'; -import type { SpacesManager } from '../spaces_manager'; +import { + spacesManagementApp, + type CreateParams as SpacesManagementAppCreateParams, +} from './spaces_management_app'; -interface SetupDeps { +interface SetupDeps extends SpacesManagementAppCreateParams { management: ManagementSetup; - getStartServices: StartServicesAccessor; - spacesManager: SpacesManager; - config: ConfigType; - getRolesAPIClient: () => Promise; - eventTracker: EventTracker; - getPrivilegesAPIClient: () => Promise; - logger: Logger; } export class ManagementService { @@ -42,6 +28,7 @@ export class ManagementService { getRolesAPIClient, eventTracker, getPrivilegesAPIClient, + getSecurityLicense, }: SetupDeps) { this.registeredSpacesManagementApp = management.sections.section.kibana.registerApp( spacesManagementApp.create({ @@ -52,6 +39,7 @@ export class ManagementService { getRolesAPIClient, eventTracker, getPrivilegesAPIClient, + getSecurityLicense, }) ); } diff --git a/x-pack/plugins/spaces/public/management/security_license.mock.ts b/x-pack/plugins/spaces/public/management/security_license.mock.ts new file mode 100644 index 0000000000000..d5d6e73d03db4 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/security_license.mock.ts @@ -0,0 +1,49 @@ +/* + * 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 { BehaviorSubject, type Observable } from 'rxjs'; + +import type { SecurityLicense } from '@kbn/security-plugin-types-public'; + +type SecurityLicenseFeatures = SecurityLicense['features$'] extends Observable ? P : never; + +export const createSecurityLicenseMock = ({ + securityFeaturesConfig, +}: { + securityFeaturesConfig: SecurityLicenseFeatures; +}): SecurityLicense => { + return { + isLicenseAvailable: jest.fn(), + isEnabled: jest.fn(), + getFeatures: jest.fn(), + getUnavailableReason: jest.fn(), + hasAtLeast: jest.fn(), + getLicenseType: jest.fn(), + features$: new BehaviorSubject(securityFeaturesConfig), + }; +}; + +export const getSecurityLicenseMock = jest.fn().mockResolvedValue( + createSecurityLicenseMock({ + securityFeaturesConfig: { + showLinks: true, + showLogin: true, + allowLogin: true, + allowRbac: true, + allowFips: true, + showRoleMappingsManagement: true, + allowAccessAgreement: true, + allowAuditLogging: true, + allowSubFeaturePrivileges: true, + allowRoleFieldLevelSecurity: true, + allowRoleDocumentLevelSecurity: true, + allowRoleRemoteIndexPrivileges: true, + allowRemoteClusterPrivileges: true, + allowUserProfileCollaboration: true, + }, + }) +); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx index a04335613e59b..dc887a7b85f13 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx @@ -77,6 +77,7 @@ async function mountApp(basePath: string, pathname: string, spaceId?: string) { logger, getRolesAPIClient: jest.fn(), getPrivilegesAPIClient: jest.fn(), + getSecurityLicense: jest.fn(), eventTracker, }) .mount({ @@ -101,6 +102,7 @@ describe('spacesManagementApp', () => { logger, getRolesAPIClient: jest.fn(), getPrivilegesAPIClient: jest.fn(), + getSecurityLicense: jest.fn(), eventTracker, }) ).toMatchInlineSnapshot(` diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx index fa74316779a7e..b5cc476f9a527 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -18,6 +18,7 @@ import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import type { PrivilegesAPIClientPublicContract, RolesAPIClient, + SecurityLicense, } from '@kbn/security-plugin-types-public'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { Route, Router, Routes } from '@kbn/shared-ux-router'; @@ -28,7 +29,7 @@ import type { ConfigType } from '../config'; import type { PluginsStart } from '../plugin'; import type { SpacesManager } from '../spaces_manager'; -interface CreateParams { +export interface CreateParams { getStartServices: StartServicesAccessor; spacesManager: SpacesManager; config: ConfigType; @@ -36,6 +37,7 @@ interface CreateParams { getRolesAPIClient: () => Promise; eventTracker: EventTracker; getPrivilegesAPIClient: () => Promise; + getSecurityLicense: () => Promise; } export const spacesManagementApp = Object.freeze({ @@ -48,6 +50,7 @@ export const spacesManagementApp = Object.freeze({ eventTracker, getRolesAPIClient, getPrivilegesAPIClient, + getSecurityLicense, }: CreateParams) { const title = i18n.translate('xpack.spaces.displayName', { defaultMessage: 'Spaces', @@ -146,6 +149,7 @@ export const spacesManagementApp = Object.freeze({ capabilities={application.capabilities} getUrlForApp={application.getUrlForApp} navigateToUrl={application.navigateToUrl} + getSecurityLicense={getSecurityLicense} serverBasePath={http.basePath.serverBasePath} getFeatures={features.getFeatures} http={http} diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 86196333c0883..595b892dfe34d 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -10,7 +10,7 @@ import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kb import type { FeaturesPluginStart } from '@kbn/features-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public'; -import type { SecurityPluginStart } from '@kbn/security-plugin-types-public'; +import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin-types-public'; import { EventTracker, registerAnalyticsContext, registerSpacesEventTypes } from './analytics'; import type { ConfigType } from './config'; @@ -114,6 +114,18 @@ export class SpacesPlugin implements Plugin { + const { security } = await core.plugins.onSetup<{ security: SecurityPluginSetup }>( + 'security' + ); + + if (!security.found) { + throw new Error('Security plugin is not available as runtime dependency.'); + } + + return security.contract.license; + }; + if (plugins.home) { plugins.home.featureCatalogue.register(createSpacesFeatureCatalogueEntry()); } @@ -129,6 +141,7 @@ export class SpacesPlugin implements Plugin