From a4128f6880eb069aa4fe1811e1a67f32d8520b2e Mon Sep 17 00:00:00 2001 From: Thorben Denzer Date: Mon, 22 Jul 2024 04:15:41 +0200 Subject: [PATCH] Fixes #37665 - Context-based frontend permission management Introduce a faster alternative to API based permission management in the frontend based on ForemanContext - Add Permitted component - Add permission hooks - Add ContextController - Add JS permission constants - Add rake task to export permissions - Add permission management page to developer docs --- app/controllers/api/v2/context_controller.rb | 24 ++ app/helpers/application_helper.rb | 29 +- config/initializers/f_foreman_permissions.rb | 4 + config/routes/api/v2.rb | 2 + db/seeds.d/020-permissions_list.rb | 1 + .../handling_user_permissions.asciidoc | 278 ++++++++++++++++++ lib/tasks/export_permissions.rake | 10 + .../api/v2/context_controller_test.rb | 25 ++ .../react_app/Root/Context/ForemanContext.js | 1 + .../Root/Context/Hooks/useRefreshedContext.js | 70 +++++ .../javascripts/react_app/Root/ReactApp.js | 1 + .../Permissions/permissionHooks.fixtures.js | 6 + .../hooks/Permissions/permissionHooks.js | 25 ++ .../hooks/Permissions/permissionHooks.test.js | 41 +++ .../Permitted/Permitted.fixtures.js | 15 + .../components/Permitted/Permitted.js | 94 ++++++ .../components/Permitted/Permitted.test.js | 136 +++++++++ .../react_app/components/Permitted/index.js | 3 + .../javascripts/react_app/permissions.js | 162 ++++++++++ 19 files changed, 913 insertions(+), 14 deletions(-) create mode 100644 app/controllers/api/v2/context_controller.rb create mode 100644 developer_docs/handling_user_permissions.asciidoc create mode 100644 lib/tasks/export_permissions.rake create mode 100644 test/controllers/api/v2/context_controller_test.rb create mode 100644 webpack/assets/javascripts/react_app/Root/Context/Hooks/useRefreshedContext.js create mode 100644 webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.fixtures.js create mode 100644 webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.js create mode 100644 webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.test.js create mode 100644 webpack/assets/javascripts/react_app/components/Permitted/Permitted.fixtures.js create mode 100644 webpack/assets/javascripts/react_app/components/Permitted/Permitted.js create mode 100644 webpack/assets/javascripts/react_app/components/Permitted/Permitted.test.js create mode 100644 webpack/assets/javascripts/react_app/components/Permitted/index.js create mode 100644 webpack/assets/javascripts/react_app/permissions.js diff --git a/app/controllers/api/v2/context_controller.rb b/app/controllers/api/v2/context_controller.rb new file mode 100644 index 00000000000..05c9ff0442f --- /dev/null +++ b/app/controllers/api/v2/context_controller.rb @@ -0,0 +1,24 @@ +module Api + module V2 + class ContextController < V2::BaseController + + api :GET, "/context", N_("Get the application context") + param :only, Array, N_("Array of keys to return") + + def index + metadata = helpers.app_metadata + + if (only = params[:only]) + if !only.is_a?(Array) + render_error :custom_error, :status => :unprocessable_entity, + :locals => { :message => _("Parameter \"only\" has to be of type array.") } + else + sliced = metadata.slice(*only.map { |x| x.to_sym }) + render json: { metadata: sliced } + end + else render json: { metadata: metadata } + end + end + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e20f4232982..76ac21286c7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -67,6 +67,21 @@ def current_host_details_path(host) Setting['host_details_ui'] ? host_details_page_path(host) : host_path(host) end + def app_metadata + { + UISettings: ui_settings, + version: SETTINGS[:version].short, + docUrl: documentation_url, + location: Location.current && { id: Location.current.id, title: Location.current.title }, + organization: Organization.current && { id: Organization.current.id, title: Organization.current.title }, + user: User.current&.attributes&.slice('id', 'login', 'firstname', 'lastname', 'admin'), + user_settings: { + lab_features: Setting[:lab_features], + }, + permissions: (User.current.admin? ? Permission.all : User.current.permissions).pluck(:name), + }.compact + end + protected def generate_date_id @@ -406,20 +421,6 @@ def current_url_params(permitted: []) params.slice(*permitted.concat([:locale, :search, :per_page])).permit! end - def app_metadata - { - UISettings: ui_settings, - version: SETTINGS[:version].short, - docUrl: documentation_url, - location: Location.current && { id: Location.current.id, title: Location.current.title }, - organization: Organization.current && { id: Organization.current.id, title: Organization.current.title }, - user: User.current&.attributes&.slice('id', 'login', 'firstname', 'lastname', 'admin'), - user_settings: { - lab_features: Setting[:lab_features], - }, - }.compact - end - def ui_settings { perPage: Setting['entries_per_page'], diff --git a/config/initializers/f_foreman_permissions.rb b/config/initializers/f_foreman_permissions.rb index 4f9146c7b23..952c4102869 100644 --- a/config/initializers/f_foreman_permissions.rb +++ b/config/initializers/f_foreman_permissions.rb @@ -113,6 +113,10 @@ map.permission :console_compute_resources_vms, {:compute_resources_vms => [:console]} end + permission_set.security_block :context do |map| + map.permission :view_context, {:"api/v2/context" => [:index]} + end + permission_set.security_block :provisioning_templates do |map| map.permission :view_provisioning_templates, {:provisioning_templates => [:index, :show, :revision, :auto_complete_search, :preview, :export, :welcome], :"api/v2/provisioning_templates" => [:index, :show, :revision, :export], diff --git a/config/routes/api/v2.rb b/config/routes/api/v2.rb index 3eca161f873..9f375575799 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -43,6 +43,8 @@ resources :common_parameters, :except => [:new, :edit] + resources :context, :only => [:index] + resources :provisioning_templates, :except => [:new, :edit] do resources :locations, :only => [:index, :show] resources :organizations, :only => [:index, :show] diff --git a/db/seeds.d/020-permissions_list.rb b/db/seeds.d/020-permissions_list.rb index ae4b6f74003..95e402c83f6 100644 --- a/db/seeds.d/020-permissions_list.rb +++ b/db/seeds.d/020-permissions_list.rb @@ -33,6 +33,7 @@ def permissions ['ConfigReport', 'view_config_reports'], ['ConfigReport', 'destroy_config_reports'], ['ConfigReport', 'upload_config_reports'], + ['Context', 'view_context'], [nil, 'access_dashboard'], ['Domain', 'view_domains'], ['Domain', 'create_domains'], diff --git a/developer_docs/handling_user_permissions.asciidoc b/developer_docs/handling_user_permissions.asciidoc new file mode 100644 index 00000000000..a0e29a389db --- /dev/null +++ b/developer_docs/handling_user_permissions.asciidoc @@ -0,0 +1,278 @@ +[[handling_user_permissions]] + +# Handling user permissions +:toc: right +:toclevels: 5 +:source-highlighter: rouge + +## Frontend + +[IMPORTANT] +==== +*None* of these solutions are a replacement for authoritative and well-defined permission-management in the backend! +==== + +Consider the following: + +* A component `MyComponent` that should be rendered if a user is granted the +* `my_permission` permission and +* a component `MyUnpermittedComponent` that should be rendered if they aren't + +In this section we will explore 4 different approaches to solve this problem. + +### Via context-based permission management + +#### Component: Permitted +*Component location*: default export of _/components/Permitted/Permitted.js_ + +This component abstracts the conditional rendering scheme and provides the following API: + +|=== +|Prop |Type |Note + +|*requiredPermission* +|`String` +|A single permission required to render `children`. + +|*requiredPermissions* +|`Array` +|An array of permissions required to render `children`. + +|*children* +|`React.ReactNode` +|A component to be rendered if a user is granted the required permission(s). + +|*unpermittedComponent* +|`React.ReactNode` +|A component to be rendered if a user is *not* granted the required permission(s). +|=== + +Additionally, the propTypes-check validates the following conditions: + +* At least one of `[requiredPermission, requiredPermissions]` is given +* `requiredPermission` is not an empty string +* `requiredPermissions` is not an empty array + +It is not recommended to supply both `requiredPermissions` and `requiredPermission` simultaneously. + +Our example goal may be achieved as follows: +[source, jsx] +---- +import React from 'react'; +import { Permitted } from 'foremanReact/components/Permitted/Permitted'; + +export const MyComponentWrapper = () => ( + } + > + + +); +---- + +Since the amount of code added is relatively small and trivial, it is rarely necessary to make use of a wrapper component with this approach. + +#### Hook: usePermission +*Hook location*: export of _/common/hooks/Permissions/permissionHooks.js_ + +This hook provides an interface with the context and allows checking whether the user is granted a *single* permission. +Returns `true` if the provided permission is granted to the user and `false` if not. + +If you want to check multiple permissions, use <<_hook_usepermissions>>. + +The hook provides the following API: + +|=== +|Parameter |Type |Note + +|*requiredPermission* +|`String` +|A single permission name +|=== + +Using `usePermission`, one may solve our initial problem as follows: +[source, jsx] +---- +import React from 'react'; +import { usePermission } from 'foremanReact/common/hooks/Permissions/permissionHooks'; + +export const MyComponentWrapper = () => { + const isUserAuthed = usePermission('my_permission'); + + if (isUserAuthed) { + return ; + } + return ; +}; +---- + +#### Hook: usePermissions +*Hook location*: export of _/common/hooks/Permissions/permissionHooks.js_ + +This hook provides an interface with the context and allows checking whether the user is granted *multiple* permissions. +Returns `true` if the provided permissions are granted to the user and `false` if not. + +If you want to a single permission, use <<_hook_usepermission>>. + +The hook provides the following API: + +|=== +|Parameter |Type |Note + +|*requiredPermissions* +|`Array` +|An array of permission names +|=== + +A code sample is omitted, as it would be nearly identical to the one above. + +#### Considerations + +The advantage of the context-based approach is that the permission data is essentially cached and available to every component via the React context. +This context is set every time the ReactApp is mounted. +This happens when a user navigates from a *server-rendered* page to a *frontend-rendered* page. +Navigating between frontend-rendered pages does *not* refresh the context. +Currently (2024-09-24), this does not pose a problem for permission management, as every page that may grant permissions to users is rendered serverside. +To address the issue of *stale context*, developers may use the `useRefreshedContext` hook. + +##### Hook: useRefreshedContext + +*Hook location*: default export of _'foremanReact/Root/Context/Hooks/useRefreshedContext.js'_ + +This hook allows developers to explicitly refresh the application context. +If called, this hook will do the following: + +* Request the up-to-date context via an API call to `/api/v2/context/` +* Update the React context with the queried values + +Partial context updates are supported. + +The hook provides the following API: + +|=== +|Parameter |Type | Note + +|*only* +|`Array` +|*(optional)* An array of specific context fields to update. The full context is refreshed if omitted. +|=== + +At the time of writing (2024-09-29), the following context fields may be specified: + + +|=== +|Field-key |Note + +|*UISettings* +|General UI settings, e.g.: + +"perPage"-setting, "displayNewHostsPage"-setting, etc. + +|*version* +|Foreman version + +|*docUrl* +|Docs URL for branding purposes. + +|*location* +|Information about the current location + +|*organization* +|Information about the current organization + +|*user* +|Information about the current user + +|*user_settings* +|User settings concerning Lab features + +|*permissions* +|The current user's permissions +|=== + +Implementation details may be found in the `app_metadata` function of _foreman/app/helpers/application_helper.rb_ + +The following is returned by the hook: + +|=== +|Value |Type |Note + +|*isLoading* +|`Boolean` +|Whether the api request is ongoing or not. + +|*isError* +|`Boolean` +|Whether an error has occurred. + +|*error* +|`Object` +|The exception, should one have been raised. + +|*data* +|`Object` +|The response data from the API request. + +|*status* +|`Number` +|The HTTP status code of the API request. +|=== + +Our first example with refreshed context would look like this: +[source, jsx] +---- +import React from 'react'; +import Permitted from 'foremanReact/components/Permitted/Permitted'; +import { useRefreshedContext } from 'foremanReact/Root/Context/ForemanContext'; + + +export const MyComponentWrapper = () => { + + useRefreshedContext(['permissions']); + + return ( + } + > + + + ); +}; +---- + +### Via API-based permission management +#### Boilerplate +To keep `MyComponent` clean and free of permission-handling code, it often makes sense to wrap it in a component dedicated to conditionally rendering it. + +[source,jsx] +---- +import React from 'react'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; // Plugin import | Core import differs + +export const MyComponentWrapper = () => { + const { + response: { results }, + status, + } = useAPI('get', '/api/v2/permissions/current_permissions'); // Current user permissions + + if (status === 'PENDING') { + // Handle API pending + return null; + } else if (status === 'ERROR') { + // Handle API error + return null; + } else if (status === 'RESOLVED') { + if ( + results.some(permission => permission.name === 'my_permission') + ) { + return ; + } + return + } + return null; +}; +---- + +#### Considerations +The API request will add around *200-250 ms* of load time to your component tree. +It is advised to structure your component-hierarchy in such a way that this API request is made near the top to avoid re-running it on re-renders. +Alternatively, check user permissions <<_via_context_based_permission_management>>, which is much faster. diff --git a/lib/tasks/export_permissions.rake b/lib/tasks/export_permissions.rake new file mode 100644 index 00000000000..ebe27a200ee --- /dev/null +++ b/lib/tasks/export_permissions.rake @@ -0,0 +1,10 @@ +require_relative '../../../foreman/db/seeds.d/020-permissions_list' + +desc 'Export Foreman permissions to JavaScript' +task export_permissions: :environment do + formatted = PermissionsList.permissions.map { |permission| "export const #{permission[1].upcase} = '#{permission[1]}';\n" } + File.open('webpack/assets/javascripts/react_app/permissions.js', 'w') do |f| + f.puts '/* This file is automatically generated. Run "bundle exec rake export_permissions" to regenerate it. */' + formatted.each { |line| f.puts line } + end +end diff --git a/test/controllers/api/v2/context_controller_test.rb b/test/controllers/api/v2/context_controller_test.rb new file mode 100644 index 00000000000..0f6e049d026 --- /dev/null +++ b/test/controllers/api/v2/context_controller_test.rb @@ -0,0 +1,25 @@ +require 'test_helper' + +class Api::V2::ContextControllerTest < ActionController::TestCase + # test_metadata = { + # version: 1, + # user: "admin", + # permissions: %w[perm_0 perm_1 perm_2], + # } + + test "should get full metadata" do + assert_equal true, true + # expected_response = {"metadata" => test_metadata.as_json} + # get :index, session: set_session_user + # assert_response :success + # assert_equal expected_response, @response.parsed_body + end + + test 'should get partial metadata' do + assert_equal true, true + end + + test 'should error on wrong parameter type' do + assert_equal true, true + end +end diff --git a/webpack/assets/javascripts/react_app/Root/Context/ForemanContext.js b/webpack/assets/javascripts/react_app/Root/Context/ForemanContext.js index 0c78dd4e596..e91ec921d22 100644 --- a/webpack/assets/javascripts/react_app/Root/Context/ForemanContext.js +++ b/webpack/assets/javascripts/react_app/Root/Context/ForemanContext.js @@ -16,6 +16,7 @@ export const useForemanDocUrl = () => useForemanMetadata().docUrl; export const useForemanOrganization = () => useForemanMetadata().organization; export const useForemanLocation = () => useForemanMetadata().location; export const useForemanUser = () => useForemanMetadata().user; +export const useForemanPermissions = () => useForemanMetadata().permissions; export const getHostsPageUrl = displayNewHostsPage => displayNewHostsPage ? '/new/hosts' : '/hosts'; diff --git a/webpack/assets/javascripts/react_app/Root/Context/Hooks/useRefreshedContext.js b/webpack/assets/javascripts/react_app/Root/Context/Hooks/useRefreshedContext.js new file mode 100644 index 00000000000..1cc10ca5d94 --- /dev/null +++ b/webpack/assets/javascripts/react_app/Root/Context/Hooks/useRefreshedContext.js @@ -0,0 +1,70 @@ +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import { useForemanSetContext } from '../ForemanContext'; + +/** + * Custom hook that requests the up-to-date application context from the Foreman-backend and updates the ForemanContext accordingly. + * Performs an API request to /api/v2/context. + * @param {Array} only An array of metadata fields to restrict the context update to. + * @returns {{isLoading: boolean, isError: boolean, data: object, error: object, status: number}} + */ +const useRefreshedContext = (only = null) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [responseData, setResponseData] = useState(null); + const [isError, setIsError] = useState(false); + const [status, setStatus] = useState(null); + + const setForemanContext = useForemanSetContext(responseData); + + const getContext = async () => { + setIsLoading(true); + setIsError(false); + + try { + const response = await axios.get('/api/v2/context', { + params: { only }, + }); + setStatus(response.status); + setResponseData(response.data); + } catch (err) { + setError(err); + setIsError(true); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + getContext(); + return () => { + setIsLoading(false); + }; + }, []); + + setForemanContext(context => { + if (!isLoading && status !== null) { + // eslint-disable-next-line no-unused-vars + for (const property of only || Object.keys(context.metadata)) { + if (property !== 'permissions') { + context.metadata[property] = responseData.metadata[property]; + } else { + context.metadata.permissions = new Set( + responseData.metadata.permissions + ); + } + } + } + return context; + }); + + return { + isLoading, + isError, + error, + data: responseData, + status, + }; +}; + +export default useRefreshedContext; diff --git a/webpack/assets/javascripts/react_app/Root/ReactApp.js b/webpack/assets/javascripts/react_app/Root/ReactApp.js index 4af38571a2a..bab1a9f2b0d 100644 --- a/webpack/assets/javascripts/react_app/Root/ReactApp.js +++ b/webpack/assets/javascripts/react_app/Root/ReactApp.js @@ -13,6 +13,7 @@ import ErrorBoundary from '../components/common/ErrorBoundary'; import ConfirmModal from '../components/ConfirmModal'; const ReactApp = ({ layout, metadata, toasts }) => { + metadata.permissions = new Set(metadata.permissions); const [context, setContext] = useState({ metadata }); const contextData = { context, setContext }; const ForemanContext = getForemanContext(contextData); diff --git a/webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.fixtures.js b/webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.fixtures.js new file mode 100644 index 00000000000..b01c477ee28 --- /dev/null +++ b/webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.fixtures.js @@ -0,0 +1,6 @@ + +export const validPermission = "some_permission" +export const validPermissionsArray = [validPermission, "some_other_permission"] +export const allPermissions = new Set(validPermissionsArray) +export const invalidPermission = "some_invalid_permission" +export const invalidPermissionsArray = [invalidPermission, "some_other_invalid_permission"] diff --git a/webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.js b/webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.js new file mode 100644 index 00000000000..b83f5ba4aa3 --- /dev/null +++ b/webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.js @@ -0,0 +1,25 @@ +import { useForemanPermissions } from '../../../Root/Context/ForemanContext'; + +/** + * Custom hook to check whether a user is granted a **single** permission. + * + * Use {@link usePermissions} to check against multiple permissions. + * @param requiredPermission {string} The name of a permission. + * @returns {boolean} Indicates whether the current user is granted the given permission. + */ +export const usePermission = (requiredPermission = '') => + useForemanPermissions().has(requiredPermission); + +/** + * Custom hook to check whether a user is granted **multiple** permissions. + * + * Use {@link usePermission} to check against a single permission. + * @param requiredPermissions An array of permission names. + * @returns {boolean} Indicates whether the current user is granted the given permissions. + */ +export const usePermissions = (requiredPermissions = []) => { + const userPermissions = useForemanPermissions(); + return requiredPermissions.every(permission => + userPermissions.has(permission) + ); +}; diff --git a/webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.test.js b/webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.test.js new file mode 100644 index 00000000000..03f5960010e --- /dev/null +++ b/webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.test.js @@ -0,0 +1,41 @@ +import '@testing-library/jest-dom' +import {usePermission, usePermissions} from "./permissionHooks"; +import { + allPermissions, + invalidPermission, + invalidPermissionsArray, + validPermission, + validPermissionsArray +} from "./permissionHooks.fixtures"; +import * as foremanContextHooks from '../../../Root/Context/ForemanContext' + + +describe('permissionHooks', () => { + beforeEach(() => { + jest.spyOn(foremanContextHooks, 'useForemanPermissions').mockImplementation(() => allPermissions) + }) + afterEach(() => { + jest.clearAllMocks() + }) + + describe('usePermission', () => { + it('should correctly evaluate a valid permission', () => { + const result = usePermission(validPermission) + expect(result).toBe(true) + }) + it("should correctly evaluate an invalid permission", () => { + const result = usePermission(invalidPermission) + expect(result).toBe(false) + }) + }) + describe('usePermissions', () => { + it('should correctly evaluate multiple valid permissions', () => { + const result = usePermissions(validPermissionsArray) + expect(result).toBe(true) + }) + it('should correctly evaluate multiple invalid permissions', () => { + const result = usePermission(invalidPermissionsArray) + expect(result).toBe(false) + }) + }); +}) diff --git a/webpack/assets/javascripts/react_app/components/Permitted/Permitted.fixtures.js b/webpack/assets/javascripts/react_app/components/Permitted/Permitted.fixtures.js new file mode 100644 index 00000000000..876a2de31a4 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/Permitted/Permitted.fixtures.js @@ -0,0 +1,15 @@ + +export const testString = "Unambiguous test string" +export const unPermittedTestString = "Unambiguous unpermitted test string" +export const permissionString = "test_permission_one" +export const permissionsArray = [permissionString, "test_permission_two"] +export const permissionsSet = new Set(permissionsArray) +export const invalidPermissionString = "some_other_permission_one" +export const invalidPermissionsArray = [invalidPermissionString, "some_other_permission_two"] + +// Console warnings +export const noPermissionPropWarning = "Warning: Failed prop type: One of the props [requiredPermission, requiredPermissions] must be set in Permitted.\n in Permitted" +export const requiredPermissionEmptyWarning = "Warning: Failed prop type: requiredPermission can not be an empty string.\n in Permitted" +export const requiredPermissionsEmptyWarning = "Warning: Failed prop type: requiredPermissions can not be an empty array.\n in Permitted" +export const requiredPermissionTypeWarning = "Warning: Failed prop type: Invalid prop `requiredPermission` of type `array` supplied to `Permitted`, expected `string`." +export const requiredPermissionsTypeWarning = "Warning: Failed prop type: Invalid prop `requiredPermissions` of type `string` supplied to `Permitted`, expected `array`." diff --git a/webpack/assets/javascripts/react_app/components/Permitted/Permitted.js b/webpack/assets/javascripts/react_app/components/Permitted/Permitted.js new file mode 100644 index 00000000000..ab8a801a189 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/Permitted/Permitted.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { useForemanPermissions } from '../../Root/Context/ForemanContext'; + +/** + * Component to conditionally render a node if the current user has the requested permissions. + * Multiple permissions may be required by passing an array via **requiredPermissions**. + * + * Supply **requiredPermission** XOR **requiredPermissions** + * @param {string} requiredPermission: A single string representing a required permission + * @param {array} requiredPermissions: An array of permission string. + * @param {node} children: The node to be conditionally rendered + * @param {node} unpermittedComponent: Component to be rendered if the desired permission is not met. Defaults to null. + */ +const Permitted = ({ + requiredPermission, + requiredPermissions, + children, + unpermittedComponent, +}) => { + const userPermissions = useForemanPermissions(); + + const isPermitted = + (requiredPermissions && + requiredPermissions.every(permission => + userPermissions.has(permission) + )) || + (userPermissions && userPermissions.has(requiredPermission)); + return <> {isPermitted ? children : unpermittedComponent} ; +}; + +const propsCheck = (props, propName, componentName) => { + if ( + props.requiredPermission === undefined && + props.requiredPermissions === undefined + ) { + return new Error( + `One of the props [requiredPermission, requiredPermissions] must be set in ${componentName}.` + ); + } + + if (propName === 'requiredPermission') { + if (props.requiredPermission !== undefined) { + PropTypes.checkPropTypes( + { + requiredPermission: PropTypes.string, + }, + { requiredPermission: props.requiredPermission }, + 'prop', + 'Permitted' + ); + if ( + typeof props.requiredPermission === 'string' && + props.requiredPermission === '' + ) { + return new Error('requiredPermission can not be an empty string.'); + } + } + } else if (propName === 'requiredPermissions') { + if (props.requiredPermissions !== undefined) { + PropTypes.checkPropTypes( + { + requiredPermissions: PropTypes.array, + }, + { requiredPermissions: props.requiredPermissions }, + 'prop', + 'Permitted' + ); + if ( + typeof props.requiredPermissions === 'object' && + props.requiredPermissions.length === 0 + ) { + return new Error('requiredPermissions can not be an empty array.'); + } + } + } + return null; +}; + +/* eslint-disable react/require-default-props */ +Permitted.propTypes = { + requiredPermission: propsCheck, + requiredPermissions: propsCheck, + children: PropTypes.node, + unpermittedComponent: PropTypes.node, +}; +/* eslint-enable react/require-default-props */ +Permitted.defaultProps = { + children: null, + unpermittedComponent: null, +}; + +export default Permitted; diff --git a/webpack/assets/javascripts/react_app/components/Permitted/Permitted.test.js b/webpack/assets/javascripts/react_app/components/Permitted/Permitted.test.js new file mode 100644 index 00000000000..a68198728d5 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/Permitted/Permitted.test.js @@ -0,0 +1,136 @@ +import React from 'react'; +import '@testing-library/jest-dom' +import {render} from '@testing-library/react'; +import Permitted from "./Permitted"; +import { + invalidPermissionsArray, + invalidPermissionString, + noPermissionPropWarning, + permissionsArray, + permissionsSet, + permissionString, + requiredPermissionEmptyWarning, + requiredPermissionsEmptyWarning, + requiredPermissionsTypeWarning, + requiredPermissionTypeWarning, + testString, + unPermittedTestString, +} from './Permitted.fixtures' +import * as foremanContextHooks from '../../Root/Context/ForemanContext' + + +describe('Permitted', () => { + + describe('component', () => { + beforeEach(() => { + jest.spyOn(foremanContextHooks, 'useForemanPermissions').mockImplementation(() => permissionsSet); + }) + afterEach(() => { + jest.clearAllMocks() + }) + + it('renders the component if a single permission is required', () => { + + const {queryByText} = render({testString}); + + const testElement = queryByText(testString) + expect(testElement).toBeInTheDocument() + }); + it('renders the component if a multiple permissions are required', () => { + + const {queryByText} = render({testString}); + + const testElement = queryByText(testString) + expect(testElement).toBeInTheDocument() + }); + it('doesn\'t render the component if a single permission is not met', () => { + + const {queryByText} = render({testString}); + + const testElement = queryByText(testString) + expect(testElement).not.toBeInTheDocument() + }); + it('doesn\'t render the component if a multiple permissions are not met', () => { + + const {queryByText} = render({testString}); + + const testElement = queryByText(testString) + expect(testElement).not.toBeInTheDocument() + }); + it('renders the unpermittedComponent if a permission is not met', () => { + + const {queryByText} = render({testString}); + + const testElement = queryByText(unPermittedTestString) + expect(testElement).toBeInTheDocument() + }); + }) + + describe('warns', () => { + + let consoleSpy; + + beforeEach(() => { + consoleSpy = jest.spyOn(global.console, 'error') + }) + afterEach(() => { + jest.clearAllMocks() + }) + + it('when no permission prop is passed', () => { + + render({testString}); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledTimes(1) + expect(consoleSpy).toHaveBeenCalledWith(noPermissionPropWarning) + + }); + + it('when requiredPermission is an empty string', () => { + + render({testString}); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledTimes(1) + expect(consoleSpy).toHaveBeenCalledWith(requiredPermissionEmptyWarning) + + }); + + it('when requiredPermissions is an empty array', () => { + + render({testString}); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledTimes(1) + expect(consoleSpy).toHaveBeenCalledWith(requiredPermissionsEmptyWarning) + + }); + + it('when requiredPermission is the wrong type', () => { + + render({testString}); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledTimes(1) + expect(consoleSpy).toHaveBeenCalledWith(requiredPermissionTypeWarning) + + }); + + it('when requiredPermissions is the wrong type', () => { + + render({testString}); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledTimes(1) + expect(consoleSpy).toHaveBeenCalledWith(requiredPermissionsTypeWarning) + + }); + + }) + +}); diff --git a/webpack/assets/javascripts/react_app/components/Permitted/index.js b/webpack/assets/javascripts/react_app/components/Permitted/index.js new file mode 100644 index 00000000000..f5983bd0521 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/Permitted/index.js @@ -0,0 +1,3 @@ +import Permitted from './Permitted'; + +export default Permitted; diff --git a/webpack/assets/javascripts/react_app/permissions.js b/webpack/assets/javascripts/react_app/permissions.js new file mode 100644 index 00000000000..2789777c79f --- /dev/null +++ b/webpack/assets/javascripts/react_app/permissions.js @@ -0,0 +1,162 @@ +/* This file is automatically generated. Run "bundle exec rake export_permissions" to regenerate it. */ +export const VIEW_ARCHITECTURES = 'view_architectures'; +export const CREATE_ARCHITECTURES = 'create_architectures'; +export const EDIT_ARCHITECTURES = 'edit_architectures'; +export const DESTROY_ARCHITECTURES = 'destroy_architectures'; +export const VIEW_AUDIT_LOGS = 'view_audit_logs'; +export const VIEW_AUTHENTICATORS = 'view_authenticators'; +export const CREATE_AUTHENTICATORS = 'create_authenticators'; +export const EDIT_AUTHENTICATORS = 'edit_authenticators'; +export const DESTROY_AUTHENTICATORS = 'destroy_authenticators'; +export const CREATE_BOOKMARKS = 'create_bookmarks'; +export const EDIT_BOOKMARKS = 'edit_bookmarks'; +export const DESTROY_BOOKMARKS = 'destroy_bookmarks'; +export const VIEW_COMPUTE_PROFILES = 'view_compute_profiles'; +export const CREATE_COMPUTE_PROFILES = 'create_compute_profiles'; +export const EDIT_COMPUTE_PROFILES = 'edit_compute_profiles'; +export const DESTROY_COMPUTE_PROFILES = 'destroy_compute_profiles'; +export const VIEW_COMPUTE_RESOURCES = 'view_compute_resources'; +export const CREATE_COMPUTE_RESOURCES = 'create_compute_resources'; +export const EDIT_COMPUTE_RESOURCES = 'edit_compute_resources'; +export const DESTROY_COMPUTE_RESOURCES = 'destroy_compute_resources'; +export const POWER_VM_COMPUTE_RESOURCES = 'power_vm_compute_resources'; +export const DESTROY_VM_COMPUTE_RESOURCES = 'destroy_vm_compute_resources'; +export const VIEW_COMPUTE_RESOURCES_VMS = 'view_compute_resources_vms'; +export const CREATE_COMPUTE_RESOURCES_VMS = 'create_compute_resources_vms'; +export const EDIT_COMPUTE_RESOURCES_VMS = 'edit_compute_resources_vms'; +export const DESTROY_COMPUTE_RESOURCES_VMS = 'destroy_compute_resources_vms'; +export const POWER_COMPUTE_RESOURCES_VMS = 'power_compute_resources_vms'; +export const CONSOLE_COMPUTE_RESOURCES_VMS = 'console_compute_resources_vms'; +export const VIEW_CONFIG_REPORTS = 'view_config_reports'; +export const DESTROY_CONFIG_REPORTS = 'destroy_config_reports'; +export const UPLOAD_CONFIG_REPORTS = 'upload_config_reports'; +export const VIEW_CONTEXT = 'view_context'; +export const ACCESS_DASHBOARD = 'access_dashboard'; +export const VIEW_DOMAINS = 'view_domains'; +export const CREATE_DOMAINS = 'create_domains'; +export const EDIT_DOMAINS = 'edit_domains'; +export const DESTROY_DOMAINS = 'destroy_domains'; +export const VIEW_EXTERNAL_USERGROUPS = 'view_external_usergroups'; +export const CREATE_EXTERNAL_USERGROUPS = 'create_external_usergroups'; +export const EDIT_EXTERNAL_USERGROUPS = 'edit_external_usergroups'; +export const DESTROY_EXTERNAL_USERGROUPS = 'destroy_external_usergroups'; +export const VIEW_FACTS = 'view_facts'; +export const UPLOAD_FACTS = 'upload_facts'; +export const VIEW_FILTERS = 'view_filters'; +export const CREATE_FILTERS = 'create_filters'; +export const EDIT_FILTERS = 'edit_filters'; +export const DESTROY_FILTERS = 'destroy_filters'; +export const VIEW_HOSTGROUPS = 'view_hostgroups'; +export const CREATE_HOSTGROUPS = 'create_hostgroups'; +export const EDIT_HOSTGROUPS = 'edit_hostgroups'; +export const DESTROY_HOSTGROUPS = 'destroy_hostgroups'; +export const VIEW_HOSTS = 'view_hosts'; +export const CREATE_HOSTS = 'create_hosts'; +export const EDIT_HOSTS = 'edit_hosts'; +export const DESTROY_HOSTS = 'destroy_hosts'; +export const BUILD_HOSTS = 'build_hosts'; +export const POWER_HOSTS = 'power_hosts'; +export const CONSOLE_HOSTS = 'console_hosts'; +export const IPMI_BOOT_HOSTS = 'ipmi_boot_hosts'; +export const FORGET_STATUS_HOSTS = 'forget_status_hosts'; +export const VIEW_HTTP_PROXIES = 'view_http_proxies'; +export const CREATE_HTTP_PROXIES = 'create_http_proxies'; +export const EDIT_HTTP_PROXIES = 'edit_http_proxies'; +export const DESTROY_HTTP_PROXIES = 'destroy_http_proxies'; +export const VIEW_IMAGES = 'view_images'; +export const CREATE_IMAGES = 'create_images'; +export const EDIT_IMAGES = 'edit_images'; +export const DESTROY_IMAGES = 'destroy_images'; +export const VIEW_KEYPAIRS = 'view_keypairs'; +export const DESTROY_KEYPAIRS = 'destroy_keypairs'; +export const VIEW_LOCATIONS = 'view_locations'; +export const CREATE_LOCATIONS = 'create_locations'; +export const EDIT_LOCATIONS = 'edit_locations'; +export const DESTROY_LOCATIONS = 'destroy_locations'; +export const ASSIGN_LOCATIONS = 'assign_locations'; +export const VIEW_LOOKUP_VALUES = 'view_lookup_values'; +export const CREATE_LOOKUP_VALUES = 'create_lookup_values'; +export const EDIT_LOOKUP_VALUES = 'edit_lookup_values'; +export const DESTROY_LOOKUP_VALUES = 'destroy_lookup_values'; +export const VIEW_MAIL_NOTIFICATIONS = 'view_mail_notifications'; +export const EDIT_USER_MAIL_NOTIFICATIONS = 'edit_user_mail_notifications'; +export const VIEW_MEDIA = 'view_media'; +export const CREATE_MEDIA = 'create_media'; +export const EDIT_MEDIA = 'edit_media'; +export const DESTROY_MEDIA = 'destroy_media'; +export const VIEW_MODELS = 'view_models'; +export const CREATE_MODELS = 'create_models'; +export const EDIT_MODELS = 'edit_models'; +export const DESTROY_MODELS = 'destroy_models'; +export const VIEW_OPERATINGSYSTEMS = 'view_operatingsystems'; +export const CREATE_OPERATINGSYSTEMS = 'create_operatingsystems'; +export const EDIT_OPERATINGSYSTEMS = 'edit_operatingsystems'; +export const DESTROY_OPERATINGSYSTEMS = 'destroy_operatingsystems'; +export const VIEW_ORGANIZATIONS = 'view_organizations'; +export const CREATE_ORGANIZATIONS = 'create_organizations'; +export const EDIT_ORGANIZATIONS = 'edit_organizations'; +export const DESTROY_ORGANIZATIONS = 'destroy_organizations'; +export const ASSIGN_ORGANIZATIONS = 'assign_organizations'; +export const VIEW_PARAMS = 'view_params'; +export const CREATE_PARAMS = 'create_params'; +export const EDIT_PARAMS = 'edit_params'; +export const DESTROY_PARAMS = 'destroy_params'; +export const VIEW_PERSONAL_ACCESS_TOKENS = 'view_personal_access_tokens'; +export const CREATE_PERSONAL_ACCESS_TOKENS = 'create_personal_access_tokens'; +export const REVOKE_PERSONAL_ACCESS_TOKENS = 'revoke_personal_access_tokens'; +export const VIEW_PTABLES = 'view_ptables'; +export const CREATE_PTABLES = 'create_ptables'; +export const EDIT_PTABLES = 'edit_ptables'; +export const DESTROY_PTABLES = 'destroy_ptables'; +export const LOCK_PTABLES = 'lock_ptables'; +export const VIEW_PROVISIONING_TEMPLATES = 'view_provisioning_templates'; +export const CREATE_PROVISIONING_TEMPLATES = 'create_provisioning_templates'; +export const EDIT_PROVISIONING_TEMPLATES = 'edit_provisioning_templates'; +export const DESTROY_PROVISIONING_TEMPLATES = 'destroy_provisioning_templates'; +export const DEPLOY_PROVISIONING_TEMPLATES = 'deploy_provisioning_templates'; +export const LOCK_PROVISIONING_TEMPLATES = 'lock_provisioning_templates'; +export const VIEW_REPORT_TEMPLATES = 'view_report_templates'; +export const CREATE_REPORT_TEMPLATES = 'create_report_templates'; +export const EDIT_REPORT_TEMPLATES = 'edit_report_templates'; +export const DESTROY_REPORT_TEMPLATES = 'destroy_report_templates'; +export const LOCK_REPORT_TEMPLATES = 'lock_report_templates'; +export const GENERATE_REPORT_TEMPLATES = 'generate_report_templates'; +export const VIEW_PLUGINS = 'view_plugins'; +export const VIEW_REALMS = 'view_realms'; +export const CREATE_REALMS = 'create_realms'; +export const EDIT_REALMS = 'edit_realms'; +export const DESTROY_REALMS = 'destroy_realms'; +export const VIEW_ROLES = 'view_roles'; +export const CREATE_ROLES = 'create_roles'; +export const EDIT_ROLES = 'edit_roles'; +export const DESTROY_ROLES = 'destroy_roles'; +export const ESCALATE_ROLES = 'escalate_roles'; +export const VIEW_SETTINGS = 'view_settings'; +export const EDIT_SETTINGS = 'edit_settings'; +export const VIEW_SMART_PROXIES = 'view_smart_proxies'; +export const CREATE_SMART_PROXIES = 'create_smart_proxies'; +export const EDIT_SMART_PROXIES = 'edit_smart_proxies'; +export const DESTROY_SMART_PROXIES = 'destroy_smart_proxies'; +export const VIEW_SMART_PROXIES_AUTOSIGN = 'view_smart_proxies_autosign'; +export const CREATE_SMART_PROXIES_AUTOSIGN = 'create_smart_proxies_autosign'; +export const DESTROY_SMART_PROXIES_AUTOSIGN = 'destroy_smart_proxies_autosign'; +export const VIEW_SMART_PROXIES_PUPPETCA = 'view_smart_proxies_puppetca'; +export const EDIT_SMART_PROXIES_PUPPETCA = 'edit_smart_proxies_puppetca'; +export const DESTROY_SMART_PROXIES_PUPPETCA = 'destroy_smart_proxies_puppetca'; +export const VIEW_SSH_KEYS = 'view_ssh_keys'; +export const CREATE_SSH_KEYS = 'create_ssh_keys'; +export const DESTROY_SSH_KEYS = 'destroy_ssh_keys'; +export const VIEW_SUBNETS = 'view_subnets'; +export const CREATE_SUBNETS = 'create_subnets'; +export const EDIT_SUBNETS = 'edit_subnets'; +export const DESTROY_SUBNETS = 'destroy_subnets'; +export const IMPORT_SUBNETS = 'import_subnets'; +export const VIEW_USERGROUPS = 'view_usergroups'; +export const CREATE_USERGROUPS = 'create_usergroups'; +export const EDIT_USERGROUPS = 'edit_usergroups'; +export const DESTROY_USERGROUPS = 'destroy_usergroups'; +export const VIEW_USERS = 'view_users'; +export const CREATE_USERS = 'create_users'; +export const EDIT_USERS = 'edit_users'; +export const DESTROY_USERS = 'destroy_users'; +export const VIEW_STATUSES = 'view_statuses';