Skip to content

Commit 2b0353f

Browse files
Thorben-Djeremylenz
authored andcommitted
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
1 parent 1d8b067 commit 2b0353f

File tree

14 files changed

+572
-0
lines changed

14 files changed

+572
-0
lines changed

app/helpers/application_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ def core_app_metadata
418418
user_settings: {
419419
lab_features: Setting[:lab_features],
420420
},
421+
permissions: (User.current.admin? ? Permission.all : User.current.permissions).pluck(:name),
421422
}.compact
422423
end
423424

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
[[handling_user_permissions]]
2+
3+
# Handling user permissions
4+
:toc: right
5+
:toclevels: 5
6+
:source-highlighter: rouge
7+
8+
## Frontend
9+
10+
[IMPORTANT]
11+
====
12+
*None* of these solutions are a replacement for authoritative and well-defined permission-management in the backend!
13+
====
14+
15+
Consider the following:
16+
17+
* A component `MyComponent` that should be rendered if a user is granted the
18+
* `my_permission` permission and
19+
* a component `MyUnpermittedComponent` that should be rendered if they aren't
20+
21+
In this section we will explore 4 different approaches to solve this problem.
22+
23+
### Via context-based permission management
24+
25+
#### Component: Permitted
26+
*Component location*: default export of _/components/Permitted/Permitted.js_
27+
28+
This component abstracts the conditional rendering scheme and provides the following API:
29+
30+
|===
31+
|Prop |Type |Note
32+
33+
|*requiredPermissions*
34+
|`Array<String>`
35+
|An array of permissions required to render `children`.
36+
37+
|*children*
38+
|`React.ReactNode`
39+
|A component to be rendered if a user is granted the required permission(s).
40+
41+
|*unpermittedComponent*
42+
|`React.ReactNode`
43+
|A component to be rendered if a user is *not* granted the required permission(s). Defaults to PermissionDenied.
44+
|===
45+
46+
Additionally, the propTypes-check validates the following conditions:
47+
48+
* `requiredPermissions` is given
49+
* `requiredPermissions` is not an empty array
50+
51+
Our example goal may be achieved as follows:
52+
[source, jsx]
53+
----
54+
import React from 'react';
55+
import { Permitted } from 'foremanReact/components/Permitted/Permitted';
56+
57+
export const MyComponentWrapper = () => (
58+
<Permitted
59+
requiredPermissions={["my_permission"]}
60+
unpermittedComponent={<MyUnpermittedComponent />}
61+
>
62+
<MyComponent />
63+
</Permitted>
64+
);
65+
----
66+
67+
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.
68+
69+
#### Hook: usePermissions
70+
*Hook location*: export of _/common/hooks/Permissions/permissionHooks.js_
71+
72+
This hook provides an interface with the context and allows checking whether the user is granted *multiple* permissions.
73+
Returns `true` if the provided permissions are granted to the user and `false` if not. +
74+
75+
The hook provides the following API:
76+
77+
|===
78+
|Parameter |Type |Note
79+
80+
|*requiredPermissions*
81+
|`Array<String>`
82+
|An array of permission names
83+
|===
84+
85+
Using `usePermissions`, one may solve our initial problem as follows:
86+
[source, jsx]
87+
----
88+
import React from 'react';
89+
import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks';
90+
91+
export const MyComponentWrapper = () => {
92+
const isUserAuthed = usePermission(['my_permission']);
93+
94+
if (isUserAuthed) {
95+
return <MyComponent />;
96+
}
97+
return <MyUnpermittedComponent />;
98+
};
99+
----
100+
#### Considerations
101+
102+
The advantage of the context-based approach is that the permission data is essentially cached and available to every component via the React context.
103+
This context is set every time the ReactApp is mounted.
104+
This happens when a user navigates from a *server-rendered* page to a *frontend-rendered* page.
105+
Navigating between frontend-rendered pages does *not* refresh the context.
106+
Currently (2025-11-14), this does not pose a problem for permission management, as every page that may grant permissions to users is rendered serverside.
107+
108+
109+
### Via API-based permission management
110+
#### Boilerplate
111+
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.
112+
113+
[source,jsx]
114+
----
115+
import React from 'react';
116+
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; // Plugin import | Core import differs
117+
118+
export const MyComponentWrapper = () => {
119+
const {
120+
response: { results },
121+
status,
122+
} = useAPI('get', '/api/v2/permissions/current_permissions'); // Current user permissions
123+
124+
if (status === 'PENDING') {
125+
// Handle API pending
126+
return null;
127+
} else if (status === 'ERROR') {
128+
// Handle API error
129+
return null;
130+
} else if (status === 'RESOLVED') {
131+
if (
132+
results.some(permission => permission.name === 'my_permission')
133+
) {
134+
return <MyComponent />;
135+
}
136+
return <MyUnpermittedComponent />
137+
}
138+
return null;
139+
};
140+
----
141+
142+
#### Considerations
143+
The API request will add around *200-250 ms* of load time to your component tree.
144+
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.
145+
Alternatively, check user permissions <<_via_context_based_permission_management>>, which is much faster.

lib/tasks/export_permissions.rake

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
require File.join(Rails.root, 'db', 'seeds.d', '020-permissions_list.rb')
2+
3+
desc 'Export Foreman permissions to JavaScript'
4+
task export_permissions: :environment do
5+
formatted = PermissionsList.permissions.map { |permission| "export const #{permission[1].upcase} = '#{permission[1]}';\n" }
6+
File.open(File.join(Rails.root, 'webpack/assets/javascripts/react_app/permissions.js'), 'w') do |f|
7+
f.puts '/* eslint-disable */'
8+
f.puts '/* This file is automatically generated. Run "bundle exec rake export_permissions" to regenerate it. */'
9+
formatted.each { |line| f.puts line }
10+
end
11+
end

webpack/assets/javascripts/react_app/Root/Context/ForemanContext.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const useForemanDocUrl = () => useForemanMetadata().docUrl;
1616
export const useForemanOrganization = () => useForemanMetadata().organization;
1717
export const useForemanLocation = () => useForemanMetadata().location;
1818
export const useForemanUser = () => useForemanMetadata().user;
19+
export const useForemanPermissions = () => useForemanMetadata().permissions;
1920

2021
export const getHostsPageUrl = displayNewHostsPage =>
2122
displayNewHostsPage ? '/new/hosts' : '/hosts';

webpack/assets/javascripts/react_app/Root/ReactApp.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import ErrorBoundary from '../components/common/ErrorBoundary';
1313
import ConfirmModal from '../components/ConfirmModal';
1414

1515
const ReactApp = ({ layout, metadata, toasts }) => {
16+
metadata.permissions = new Set(metadata.permissions);
1617
const [context, setContext] = useState({ metadata });
1718
const contextData = { context, setContext };
1819
const ForemanContext = getForemanContext(contextData);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
2+
export const validPermission = "test_permission_one"
3+
export const validPermissionsArray = [validPermission, "test_permission_two"]
4+
export const invalidPermission = "test_permission_one_invalid"
5+
export const invalidPermissionsArray = [invalidPermission, "test_permission_two_invalid"]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useForemanPermissions } from '../../../Root/Context/ForemanContext';
2+
3+
/**
4+
* Custom hook to check whether a user is granted an array of permissions.
5+
*
6+
* @param requiredPermissions An array of permission names.
7+
* @returns {boolean} Indicates whether the current user is granted the given permissions.
8+
*/
9+
export const usePermissions = (requiredPermissions = []) => {
10+
const userPermissions = useForemanPermissions();
11+
return requiredPermissions.every(permission =>
12+
userPermissions.has(permission)
13+
);
14+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import '@testing-library/jest-dom'
2+
import {usePermissions} from "./permissionHooks";
3+
import {
4+
invalidPermissionsArray,
5+
validPermissionsArray
6+
} from "./permissionHooks.fixtures";
7+
8+
9+
describe('permissionHooks', () => {
10+
11+
describe('usePermissions', () => {
12+
it('should correctly evaluate multiple valid permissions', () => {
13+
const result = usePermissions(validPermissionsArray)
14+
expect(result).toBe(true)
15+
})
16+
it('should correctly evaluate multiple invalid permissions', () => {
17+
const result = usePermissions(invalidPermissionsArray)
18+
expect(result).toBe(false)
19+
})
20+
});
21+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
2+
export const testString = "Unambiguous test string"
3+
export const unPermittedTestString = "Unambiguous unpermitted test string"
4+
export const permissionString = "test_permission_one"
5+
export const permissionsArray = [permissionString, "test_permission_two"]
6+
export const invalidPermissionString = "some_other_permission_one"
7+
export const invalidPermissionsArray = [invalidPermissionString, "some_other_permission_two"]
8+
9+
// Console warnings
10+
export const noPermissionPropWarning =
11+
'Warning: Failed prop type: The prop \"requiredPermissions\" must be set in Permitted.\n in Permitted';
12+
export const requiredPermissionsEmptyWarning = "Warning: Failed prop type: requiredPermissions can not be an empty array.\n in Permitted"
13+
export const requiredPermissionsTypeWarning = "Warning: Failed prop type: Invalid prop `requiredPermissions` of type `string` supplied to `Permitted`, expected `array`."
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
4+
import { useForemanPermissions } from '../../Root/Context/ForemanContext';
5+
import PermissionDenied from '../PermissionDenied';
6+
7+
/**
8+
* Component to conditionally render a node if the current user has the requested permissions.
9+
* Multiple permissions may be required by passing an array via **requiredPermissions**.
10+
*
11+
* @param {array<string>} requiredPermissions: An array of permission string.
12+
* @param {node} children: The node to be conditionally rendered
13+
* @param {node} unpermittedComponent: Component to be rendered if the desired permission is not met. Defaults to null.
14+
*/
15+
const Permitted = ({ requiredPermissions, children, unpermittedComponent }) => {
16+
const userPermissions = useForemanPermissions();
17+
18+
const isPermitted =
19+
requiredPermissions &&
20+
requiredPermissions.every(permission => userPermissions.has(permission));
21+
return (
22+
<>
23+
{' '}
24+
{isPermitted
25+
? children
26+
: unpermittedComponent || (
27+
<PermissionDenied
28+
missingPermissions={
29+
typeof requiredPermissions === 'object'
30+
? requiredPermissions.filter(
31+
rPerm => !userPermissions.has(rPerm)
32+
)
33+
: []
34+
}
35+
/>
36+
)}{' '}
37+
</>
38+
);
39+
};
40+
41+
const propsCheck = (props, propName, componentName) => {
42+
if (props.requiredPermissions === undefined) {
43+
return new Error(
44+
`The prop "requiredPermissions" must be set in ${componentName}.`
45+
);
46+
}
47+
48+
PropTypes.checkPropTypes(
49+
{
50+
requiredPermissions: PropTypes.array,
51+
},
52+
{ requiredPermissions: props.requiredPermissions },
53+
'prop',
54+
'Permitted'
55+
);
56+
if (
57+
typeof props.requiredPermissions === 'object' &&
58+
props.requiredPermissions.length === 0
59+
) {
60+
return new Error('requiredPermissions can not be an empty array.');
61+
}
62+
63+
return null;
64+
};
65+
66+
/* eslint-disable react/require-default-props */
67+
Permitted.propTypes = {
68+
requiredPermissions: propsCheck,
69+
children: PropTypes.node,
70+
unpermittedComponent: PropTypes.node,
71+
};
72+
/* eslint-enable react/require-default-props */
73+
Permitted.defaultProps = {
74+
children: null,
75+
unpermittedComponent: null,
76+
};
77+
78+
export default Permitted;

0 commit comments

Comments
 (0)