Skip to content

Commit

Permalink
Fixes #37665 - Context-based frontend permission management
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Thorben-D committed Oct 1, 2024
1 parent 5fdab0f commit a4128f6
Show file tree
Hide file tree
Showing 19 changed files with 913 additions and 14 deletions.
24 changes: 24 additions & 0 deletions app/controllers/api/v2/context_controller.rb
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
29 changes: 15 additions & 14 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'],
Expand Down
4 changes: 4 additions & 0 deletions config/initializers/f_foreman_permissions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
2 changes: 2 additions & 0 deletions config/routes/api/v2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions db/seeds.d/020-permissions_list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
278 changes: 278 additions & 0 deletions developer_docs/handling_user_permissions.asciidoc
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.
10 changes: 10 additions & 0 deletions lib/tasks/export_permissions.rake
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|
f.puts '/* This file is automatically generated. Run "bundle exec rake export_permissions" to regenerate it. */'
formatted.each { |line| f.puts line }
end
end
Loading

0 comments on commit a4128f6

Please sign in to comment.