Skip to content

Conversation

@Thorben-D
Copy link
Contributor

Redmine issue
Community-forum post this implementation is based on

This PR implements a faster way to check user permissions in Foreman core/plugin frontend code. This speed advantage is gained by leveraging the ForemanContext to store the current user's permissions.

Changes made:

  • Extended: ForemanContext
    Added permissions field to context. Contains the current user's permissions - Implmenented as a set of permission names.
  • Added: Permitted
    A component that abstracts the conditional rendering scheme of "Render if <permission> is granted"
    • Developers may require a single or multiple permissions
    • Supports rendering a different component if the permissions are not granted
  • Added: usePermission/s
    Two custom hooks that allow checking whether a is granted a single or multiple permissions
  • Added: useForemanPermissions
    Context hook that provides a reference to the actual permissions set
  • Added: useRefreshedContext
    A custom hook that refreshes the ForemanContext by requesting the up-to-date application state from the backend
    • automatically sets the ForemanContext when called
    • Supports partial context updates
  • Added: ContextController at api/v2/context
    Controller that interfaces with the application context on the backend
    • Only supports reading of the context at the moment
    • Requires view_context permission
  • Added: Foreman-core permissions as JS constants
  • Added: Rake task to generate JS permission constants
  • Added: DeveloperDocs page on permission handling

Performance

Five samples taken on a decently fast system using FireFox developer edition with cache disabled and no background tasks running.
Different approaches tested using this component.

API-based approach

Sample Total page load time (s) API request duration (ms)
1 2.14 197
2 2.07 156
3 2.05 176
4 2.09 197
5 2.16 184
Mean 2.10 182

Permitted component

Sample Total page load time (s)
1 1.98
2 1.96
3 1.91
4 1.99
5 1.99
Mean 1.97

Permitted component in conjunction with useRefreshedContext()

Sample Total page load time (s) API request duration (ms)
1 1.91 158
2 2.00 146
3 1.92 167
4 1.92 149
5 2.00 154
Mean 1.95 155

Discussion

Although it may seem like the difference between Permitted and Permitted + useRefreshedContext is negligible, this is not the case, as the actual time to mount the component differs by at least the API request duration when using useRefreshedContext. Unfortunately, I don't have any values there because the React profiler is ...not great.
Even with a full page-reload, a speed difference of ~7% may be observed. This will be amplified on weaker / busier systems than mine. Furthermore, navigation between frontend-rendered pages will benefit a lot more, as comparatively little data needs to be requested from the backend.
Unfortunately, I nuked my dev-environment shortly after collecting the data above, so I will have to amend the results for client-rendered -> client-rendered later :)

The case for permission constants in JS

Although not strictly in the scope of this issue, I believe that this presents a good opportunity to introduce permission constants for JS, similar to API status constants. These constants mainly offer the following benefits:

  • Should it ever become necessary to rename a permission, having them constantized makes this very easy
  • Modern IDEs can use these constants to perform analyses such as usage finding.

To aid developers in creating these constants, I created a rake task, export_permissions, that automatically generates these JS constants from the permission seed file.

TODO

  • ContextController test:
    I tried a lot of things, but I didn't manage to mock the app_metadata function, so the test is useless at the moment
  • useRefreshedContext test:
    Since this hook interfaces pretty deeply with the context and the API, testing it is rather difficult. I got pretty far with renderHook and mocks but am running into issues with the setForemanContext function.

@MariaAga
Copy link
Member

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.

Does this mean that useRefreshedContext is for when a user is on reactPage1, their permissions change from a different page, and then they navigate to reactPage2? so the context permission list is incorrect since its not been refreshed?

@ekohl
Copy link
Member

ekohl commented Oct 11, 2024

To aid developers in creating these constants, I created a rake task, export_permissions, that automatically generates these JS constants from the permission seed file.

My programming professor from university approves the use of constants. I just wonder about the plugin use case. Can we make it easy for plugins to do the same?

@Thorben-D
Copy link
Contributor Author

Does this mean that useRefreshedContext is for when a user is on reactPage1, their permissions change from a different page, and then they navigate to reactPage2? so the context permission list is incorrect since its not been refreshed?

Yes, exactly! As I wrote in the docs, this is not really needed (yet) in regards to permissions, but it may be of use for UISettings.
On a different note: Do you know what causes the tests to fail? Obviously I ran the tests manually before pushing, but I used my IDE to do so, which calls node directly. I didn't think it would make a difference but when using tfm-test, I get the same errors...

@MariaAga
Copy link
Member

Tests (at least some) are failing since we mock the context in webpack/test_setup.js, so you can move the mock from jest.spyOn(foremanContextHooks, 'useForemanPermissions').mockImplementation(() => allPermissions) to that file.
Also an easy way to test only your tests is to run npm test -- webpack/assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.test.js
its a bit weird, but the code will be: (because of jest scopes)

import { useForemanPermissions } from './assets/javascripts/react_app/Root/Context/ForemanContext';
import { allPermissions } from './assets/javascripts/react_app/common/hooks/Permissions/permissionHooks.fixtures';
...
  getHostsPageUrl: displayNewHostsPage =>
    displayNewHostsPage ? '/new/hosts' : '/hosts',
  useForemanPermissions: jest.fn(),
...
useForemanPermissions.mockReturnValue(allPermissions);

@MariaAga
Copy link
Member

Also, I tried the wrapper and it works nicely!
in webpack/assets/javascripts/react_app/components/HostDetails/Tabs/ReportsTab/index.js

const ReportsTabWrapper = props => (
  <Permitted
    requiredPermissions={['view_config_reports']}
    unpermittedComponent={
      <PermissionDenied missingPermissions={['TESTview_config_reports']} />
    }
  >
    <ReportsTab {...props} />
  </Permitted>
);

export default ReportsTabWrapper;

@Thorben-D Thorben-D force-pushed the fixes/37665_context_based_react_permissions branch 5 times, most recently from f3f38d0 to cfe151a Compare November 21, 2025 14:19
@Thorben-D
Copy link
Contributor Author

@MariaAga

Wow, talk about response time...
Basically, this was something I did on some weekend and... I just forgot about it. Truly sorry about that.

I made the following changes:

  • I removed the single permission handling stuff as it is redundant
  • Context is now correctly mocked, which fixed tests
  • Permitted returns PermissionDenied by default now
  • I used Rails.root in the rake task, which fixed the RPM build
  • The generated permissions.js includes an eslint-disable

Copy link
Contributor

@jeremylenz jeremylenz left a comment

Choose a reason for hiding this comment

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

Thanks @Thorben-D! This looks like a great addition. My only concern is the API use case - The whole point of ForemanContext (well, maybe not the whole point, but one of the huge advantages) is to avoid the frontend-backend API call. I'm curious your reasoning for it?

@Thorben-D
Copy link
Contributor Author

@jeremylenz
Hi Jeremy, thanks for taking a look!
I'm not quite sure I follow though...
The context is (at least in theory and assuming a 100% React app) only set once when the root app mounts.
Are you referring to the useRefreshedContext() hook?
If so, that hook only exists for "future proofing".
For example, if the organization switcher doesn't have to reload the page anymore at some point, the context would not be updated and still retain the previous organization.
To mitigate that, the organization switcher would then call that hook to explicitly reload the context.

To be perfectly honest, I can't really find a use-case for the useRefreshedContext() hook yet, but I thought I'd include it for the future.
Other than that, no API requests are made.

@Thorben-D
Copy link
Contributor Author

How can we proceed here?
I have used this extensively downstream over the last months and found it to work without issue.
If the code is not up to scratch in parts, please let me know.

@jeremylenz
Copy link
Contributor

I would prefer that useRefreshedContext is not included yet, if it's not used. Seems better not to include unused code. Also, I would like soon to improve my changes in #10713 so that the registered metadata is evaluated in a lambda, which should mitigate the issue a bit.

Other than that, I am good with moving this forward. I see a CI failure; I didn't check if it's related but perhaps a rebase will help?

@Thorben-D
Copy link
Contributor Author

@jeremylenz
Thanks for your comment.
I actually didn't have to use the useRefreshedContext hook at all either, so I agree that it is best to remove it for now.
This should also simplify the code in this PR quite a bit too. I'll keep it somewhere just in case, but with how often the context is refreshed "automatically" at the moment, it really isn't needed.

I'll have the PR fixed up by the end of the week.

I'm responding now though because you mentioned app_metadata_registry.
I remember trying to use it to pass some value (a setting I think) to the frontend and wanted to use this. As you suggested, it was not usable for that, since the value would only be set once.
I wanted to improve it myself, but I rolled my own context at some point, so I never got past this:

--- a/app/registries/foreman/plugin/app_metadata_registry.rb
+++ b/app/registries/foreman/plugin/app_metadata_registry.rb
@@ -6,16 +6,35 @@ module Foreman
       end
 
       def register(plugin_name, data = {})
-        unless data.is_a?(Hash) || data.respond_to?(:to_h)
+        unless data.is_a?(Hash) || data.respond_to?(:to_h) || data.respond_to?(:call)
           Rails.logger.warn "AppMetadataRegistry: data for plugin #{plugin_name} is not a hash or compatible type; registration skipped."
           return
         end
-        @plugin_metadata[plugin_name] = data.is_a?(Hash) ? data : data.to_h
+        @plugin_metadata[plugin_name] = data.is_a?(Hash) || data.respond_to?(:call) ? data : data.to_h
         Rails.logger.info "Registered app metadata for plugin #{plugin_name}"
       end
 
       def all_plugin_metadata
-        @plugin_metadata
+        render_hash @plugin_metadata
+      end
+
+      private
+
+      def render_hash(hash)
+        if hash.respond_to?(:call)
+          return render_hash(hash.call)  # Recurse in case lambda returns a hash
+        elsif !hash.is_a?(Hash)
+          return hash
+        end
+
+        sub_hash = {}
+
+        hash.each_pair do |key, value|
+          new_key = key.respond_to?(:call) ? render_hash(key.call) : key
+          sub_hash[new_key] = render_hash(value)
+        end
+
+        sub_hash
       end
     end
   end

Far from perfect, but maybe it can serve you as a start.

@Thorben-D
Copy link
Contributor Author

I have removed the useRefreshedContext hook, which made most of the Ruby changes obsolete.
I removed those aswell and adjusted docs.
Rebasing also seems to have fixed the tests.

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
@Thorben-D Thorben-D force-pushed the fixes/37665_context_based_react_permissions branch from f38ae60 to 6c5880b Compare January 9, 2026 16:04
@Thorben-D
Copy link
Contributor Author

Great... I removed the new compact UI mode.
--> Fixed

@jeremylenz
Copy link
Contributor

I removed the new compact UI mode.

Why remove that? don't we need it?

@Thorben-D
Copy link
Contributor Author

I removed the new compact UI mode.

Why remove that? don't we need it?

Definitely!
I was lazy and just copy-pasted the core_app_metadata method back to where it was before. That code predates the compact UI mode by quite a bit, so I accidentally undid the changes introduced by the compact UI PR.
I added it back again of course.

@jeremylenz
Copy link
Contributor

@Thorben-D Check out #10814, fyi :)

Copy link
Contributor

@jeremylenz jeremylenz left a comment

Choose a reason for hiding this comment

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

Can we include actual use of the Permitted component?

It seems to work well for me if we replace the PermissionDenied pattern in webpack/assets/javascripts/react_app/routes/RegistrationCommands/RegistrationCommandsPage/index.js :

return (
    <Permitted
      requiredPermissions={['register_hosts']}
      unpermittedComponent={
        <PermissionDenied missingPermissions={['register_hosts']} />
      }
    >
      <PageLayout
...

@Thorben-D
Copy link
Contributor Author

I get where you're coming from and I can do that of course, but that seems a bit out of the scope of this PR to me.
I'd rather do that as a follow-up to keep the commit "clean".

@jeremylenz
Copy link
Contributor

jeremylenz commented Jan 12, 2026

@MariaAga thoughts? (I have tested it locally using my suggestion above, and it works)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants