-
Notifications
You must be signed in to change notification settings - Fork 991
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fixes #37665 - Context-based frontend permission management #10338
Open
Thorben-D
wants to merge
1
commit into
theforeman:develop
Choose a base branch
from
Thorben-D:fixes/37665_context_based_react_permissions
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String>` | ||
|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 = () => ( | ||
<Permitted | ||
requiredPermission="my_permission" | ||
unpermittedComponent={<MyUnpermittedComponent />} | ||
> | ||
<MyComponent /> | ||
</Permitted> | ||
); | ||
---- | ||
|
||
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 <MyComponent />; | ||
} | ||
return <MyUnpermittedComponent />; | ||
}; | ||
---- | ||
|
||
#### 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<String>` | ||
|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<String>` | ||
|*(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 ( | ||
<Permitted | ||
requiredPermission="my_permission" | ||
unpermittedComponent={<MyUnpermittedComponent />} | ||
> | ||
<MyComponent /> | ||
</Permitted> | ||
); | ||
}; | ||
---- | ||
|
||
### 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 <MyComponent />; | ||
} | ||
return <MyUnpermittedComponent /> | ||
} | ||
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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| | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd like to use |
||
f.puts '/* This file is automatically generated. Run "bundle exec rake export_permissions" to regenerate it. */' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can it also put |
||
formatted.each { |line| f.puts line } | ||
end | ||
end |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.