Skip to content
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
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
else render json: { metadata: metadata }
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|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to use Rails.root as a base for a complete path in case something reuses this

f.puts '/* This file is automatically generated. Run "bundle exec rake export_permissions" to regenerate it. */'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can it also put /* eslint-disable */ at the top of the file?

formatted.each { |line| f.puts line }
end
end
Loading
Loading