diff --git a/app/controllers/insights_cloud/ui_requests_controller.rb b/app/controllers/insights_cloud/ui_requests_controller.rb
index 6777e5ef2..67f7847ba 100644
--- a/app/controllers/insights_cloud/ui_requests_controller.rb
+++ b/app/controllers/insights_cloud/ui_requests_controller.rb
@@ -18,6 +18,10 @@ def forward_request
@organization,
@location
)
+ rescue ::Foreman::Exception => e
+ logger.warn("Permission denied for forwarding request: #{e}")
+ message = e.message
+ return render json: { message: message, error: message }, status: :forbidden
rescue RestClient::Exceptions::Timeout => e
response_obj = e.response.presence || e.exception
return render json: { message: response_obj.to_s, error: response_obj.to_s }, status: :gateway_timeout
diff --git a/app/services/foreman_rh_cloud/insights_api_forwarder.rb b/app/services/foreman_rh_cloud/insights_api_forwarder.rb
index 73aa2f141..996b0c9ff 100644
--- a/app/services/foreman_rh_cloud/insights_api_forwarder.rb
+++ b/app/services/foreman_rh_cloud/insights_api_forwarder.rb
@@ -4,17 +4,125 @@ module ForemanRhCloud
class InsightsApiForwarder
include ForemanRhCloud::CertAuth
+ # Permission mapping for API paths:
+ #
+ # Foreman Permission | Paths
+ # ---------------------|--------------------------------------------------
+ # view_vulnerability | GET /api/inventory/v1/hosts(/*)
+ # view_vulnerability | GET /api/vulnerability/v1/*
+ # | POST /api/vulnerability/v1/vulnerabilities/cves
+ # edit_vulnerability | PATCH /api/vulnerability/v1/status
+ # edit_vulnerability | PATCH /api/vulnerability/v1/cves/status
+ # | PATCH /api/vulnerability/v1/cves/business_risk
+ # edit_vulnerability | PATCH /api/vulnerability/v1/systems/opt_out
+ #
+ # view_advisor | GET /api/insights/v1/*
+ # edit_advisor | POST /api/insights/v1/ack/
+ # | DELETE /api/insights/v1/ack/{rule_id}/
+ # | POST /api/insights/v1/hostack/
+ # | DELETE /api/insights/v1/hostack/{id}/
+ # | POST /api/insights/v1/rule/{rule_id}/unack_hosts/
+ #
SCOPED_REQUESTS = [
- { test: %r{api/vulnerability/v1/vulnerabilities/cves}, tag_name: :tags },
+ # Inventory hosts - requires view_vulnerability for GET
+ {
+ test: %r{api/inventory/v1/hosts(/.*)?$},
+ tag_name: :tags,
+ permissions: {
+ 'GET' => :view_vulnerability,
+ },
+ },
+ # Vulnerability CVEs list - POST requires view_vulnerability (for filtering)
+ {
+ test: %r{api/vulnerability/v1/vulnerabilities/cves},
+ tag_name: :tags,
+ permissions: {
+ 'POST' => :view_vulnerability,
+ },
+ },
+ # Vulnerability status - PATCH requires edit_vulnerability
+ {
+ test: %r{api/vulnerability/v1/status},
+ tag_name: :tags,
+ permissions: {
+ 'PATCH' => :edit_vulnerability,
+ },
+ },
+ # CVE status - PATCH requires edit_vulnerability
+ {
+ test: %r{api/vulnerability/v1/cves/status},
+ tag_name: :tags,
+ permissions: {
+ 'PATCH' => :edit_vulnerability,
+ },
+ },
+ # CVE business risk - PATCH requires edit_vulnerability
+ {
+ test: %r{api/vulnerability/v1/cves/business_risk},
+ tag_name: :tags,
+ permissions: {
+ 'PATCH' => :edit_vulnerability,
+ },
+ },
+ # Systems opt out - PATCH requires edit_vulnerability
+ {
+ test: %r{api/vulnerability/v1/systems/opt_out},
+ tag_name: :tags,
+ permissions: {
+ 'PATCH' => :edit_vulnerability,
+ },
+ },
+ # Other vulnerability endpoints (no specific permission enforcement, just tagging)
{ test: %r{api/vulnerability/v1/dashbar}, tag_name: :tags },
{ test: %r{api/vulnerability/v1/cves/[^/]+/affected_systems}, tag_name: :tags },
{ test: %r{api/vulnerability/v1/systems/[^/]+/cves}, tag_name: :tags },
- { test: %r{api/insights/.*}, tag_name: :tags },
+ # Advisor ack endpoints - POST/DELETE require edit_advisor
+ {
+ test: %r{api/insights/v1/ack(/[^/]*)?$},
+ tag_name: :tags,
+ permissions: {
+ 'POST' => :edit_advisor,
+ 'DELETE' => :edit_advisor,
+ },
+ },
+ # Advisor hostack endpoints - POST/DELETE require edit_advisor
+ {
+ test: %r{api/insights/v1/hostack(/[^/]*)?$},
+ tag_name: :tags,
+ permissions: {
+ 'POST' => :edit_advisor,
+ 'DELETE' => :edit_advisor,
+ },
+ },
+ # Advisor rule unack_hosts - POST requires edit_advisor
+ {
+ test: %r{api/insights/v1/rule/[^/]+/unack_hosts},
+ tag_name: :tags,
+ permissions: {
+ 'POST' => :edit_advisor,
+ },
+ },
+ # Other Advisor/Insights endpoints - GET requires view_advisor
+ {
+ test: %r{api/insights/v1/.*},
+ tag_name: :tags,
+ permissions: {
+ 'GET' => :view_advisor,
+ },
+ },
+ # Other API endpoints (tagging only, no permission enforcement)
{ test: %r{api/inventory/.*}, tag_name: :tags },
{ test: %r{api/tasks/.*}, tag_name: :tags },
].freeze
def forward_request(original_request, path, controller_name, user, organization, location)
+ # Check permissions before forwarding
+ permission = required_permission_for(path, original_request.request_method)
+ if permission && !user&.can?(permission)
+ logger.warn("User #{user.login} lacks permission #{permission} for #{original_request.request_method} #{path}")
+ raise ::Foreman::Exception.new(N_("You do not have permission to perform this action"))
+ end
+
TagsAuth.new(user, organization, location, logger).update_tag if scope_request?(original_request, path)
forward_params = prepare_forward_params(original_request, path, user: user, organization: organization, location: location).to_a
@@ -116,5 +224,19 @@ def http_user_agent(original_request)
def logger
Foreman::Logging.logger('app')
end
+
+ # Returns the required permission for the given path and HTTP method
+ # @param path [String] The request path
+ # @param http_method [String] The HTTP method (GET, POST, etc.)
+ # @return [Symbol, nil] The required permission symbol or nil if no permission required
+ def required_permission_for(path, http_method)
+ request_pattern = SCOPED_REQUESTS.find { |pattern| pattern[:test].match?(path) }
+ return nil unless request_pattern
+
+ permissions = request_pattern[:permissions]
+ return nil unless permissions
+
+ permissions[http_method]
+ end
end
end
diff --git a/lib/foreman_rh_cloud/plugin.rb b/lib/foreman_rh_cloud/plugin.rb
index 5570df853..089e10d62 100644
--- a/lib/foreman_rh_cloud/plugin.rb
+++ b/lib/foreman_rh_cloud/plugin.rb
@@ -71,9 +71,37 @@ def self.register
:control_organization_insights,
'insights_cloud/settings': [:set_org_parameter]
)
+ # Insights Vulnerability permissions
+ permission(
+ :view_vulnerability,
+ {},
+ :resource_type => 'ForemanRhCloud'
+ )
+ permission(
+ :edit_vulnerability,
+ {},
+ :resource_type => 'ForemanRhCloud'
+ )
+ # Insights Advisor permissions
+ permission(
+ :view_advisor,
+ {},
+ :resource_type => 'ForemanRhCloud'
+ )
+ permission(
+ :edit_advisor,
+ {},
+ :resource_type => 'ForemanRhCloud'
+ )
end
- plugin_permissions = [:view_foreman_rh_cloud, :generate_foreman_rh_cloud, :view_insights_hits, :dispatch_cloud_requests, :control_organization_insights]
+ # Core RH Cloud permissions for inventory upload and sync
+ rh_cloud_permissions = [:view_foreman_rh_cloud, :generate_foreman_rh_cloud, :view_insights_hits, :dispatch_cloud_requests, :control_organization_insights]
+
+ # Insights application permissions (Vulnerability, Advisor)
+ insights_permissions = [:view_vulnerability, :edit_vulnerability, :view_advisor, :edit_advisor]
+
+ plugin_permissions = rh_cloud_permissions + insights_permissions
role 'ForemanRhCloud', plugin_permissions, 'Role granting permissions to view the hosts inventory,
generate a report, upload it to the cloud and download it locally'
@@ -149,7 +177,7 @@ def self.register
add_pagelet :hosts_table_column_content, key: :insights_recommendations_count, callback: ->(host) { hits_counts_cell(host) }, class: 'hidden-xs ellipsis text-center', priority: 100
end
end
-
+
::Foreman::Plugin.app_metadata_registry.register(:foreman_rh_cloud, {
iop: -> { ForemanRhCloud.with_iop_smart_proxy? },
})
diff --git a/test/unit/services/foreman_rh_cloud/insights_api_forwarder_test.rb b/test/unit/services/foreman_rh_cloud/insights_api_forwarder_test.rb
index 4adba1046..9379216e2 100644
--- a/test/unit/services/foreman_rh_cloud/insights_api_forwarder_test.rb
+++ b/test/unit/services/foreman_rh_cloud/insights_api_forwarder_test.rb
@@ -214,4 +214,429 @@ def tag_name(param_value)
tag_string = CGI.unescape(param_value)
tag_string.split('=')[0]
end
+
+ # Permission enforcement tests
+
+ # GET /api/inventory/v1/hosts requires view_vulnerability
+ test 'should allow GET request to inventory hosts when user has view_vulnerability permission' do
+ user_agent = { :foo => :bar }
+ params = {}
+
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/inventory/v1/hosts',
+ 'REQUEST_METHOD' => 'GET',
+ 'HTTP_USER_AGENT' => user_agent,
+ 'rack.input' => ::Puma::NullIO.new,
+ 'action_dispatch.request.query_parameters' => params
+ )
+
+ @user.stubs(:can?).with(:view_vulnerability).returns(true)
+ ::ForemanRhCloud::TagsAuth.any_instance.expects(:update_tag)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/inventory/v1/hosts', 'test_controller', @user, @organization, @location)
+ end
+
+ test 'should deny GET request to inventory hosts when user lacks view_vulnerability permission' do
+ user_agent = { :foo => :bar }
+ params = {}
+
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/inventory/v1/hosts',
+ 'REQUEST_METHOD' => 'GET',
+ 'HTTP_USER_AGENT' => user_agent,
+ 'rack.input' => ::Puma::NullIO.new,
+ 'action_dispatch.request.query_parameters' => params
+ )
+
+ @user.stubs(:can?).with(:view_vulnerability).returns(false)
+
+ assert_raises(::Foreman::Exception) do
+ @forwarder.forward_request(req, 'api/inventory/v1/hosts', 'test_controller', @user, @organization, @location)
+ end
+ end
+
+ # POST /api/vulnerability/v1/vulnerabilities/cves requires view_vulnerability
+ test 'should allow POST request to vulnerabilities cves when user has view_vulnerability permission' do
+ post_data = '{"test": "data"}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/vulnerability/v1/vulnerabilities/cves',
+ 'REQUEST_METHOD' => 'POST',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => post_data
+ )
+
+ @user.stubs(:can?).with(:view_vulnerability).returns(true)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/vulnerability/v1/vulnerabilities/cves', 'test_controller', @user, @organization, @location)
+ end
+
+ test 'should deny POST request to vulnerabilities cves when user lacks view_vulnerability permission' do
+ post_data = '{"test": "data"}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/vulnerability/v1/vulnerabilities/cves',
+ 'REQUEST_METHOD' => 'POST',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => post_data
+ )
+
+ @user.stubs(:can?).with(:view_vulnerability).returns(false)
+
+ assert_raises(::Foreman::Exception) do
+ @forwarder.forward_request(req, 'api/vulnerability/v1/vulnerabilities/cves', 'test_controller', @user, @organization, @location)
+ end
+ end
+
+ # PATCH /api/vulnerability/v1/status requires edit_vulnerability
+ test 'should allow PATCH request to vulnerability status when user has edit_vulnerability permission' do
+ patch_data = '{"status": "resolved"}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/vulnerability/v1/status',
+ 'REQUEST_METHOD' => 'PATCH',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => patch_data,
+ "action_dispatch.request.path_parameters" => { :format => "json" }
+ )
+
+ @user.stubs(:can?).with(:edit_vulnerability).returns(true)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/vulnerability/v1/status', 'test_controller', @user, @organization, @location)
+ end
+
+ test 'should deny PATCH request to vulnerability status when user lacks edit_vulnerability permission' do
+ patch_data = '{"status": "resolved"}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/vulnerability/v1/status',
+ 'REQUEST_METHOD' => 'PATCH',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => patch_data,
+ "action_dispatch.request.path_parameters" => { :format => "json" }
+ )
+
+ @user.stubs(:can?).with(:edit_vulnerability).returns(false)
+
+ assert_raises(::Foreman::Exception) do
+ @forwarder.forward_request(req, 'api/vulnerability/v1/status', 'test_controller', @user, @organization, @location)
+ end
+ end
+
+ # PATCH /api/vulnerability/v1/cves/business_risk requires edit_vulnerability
+ test 'should allow PATCH request to cves business_risk when user has edit_vulnerability permission' do
+ patch_data = '{"business_risk": 3}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/vulnerability/v1/cves/business_risk',
+ 'REQUEST_METHOD' => 'PATCH',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => patch_data,
+ "action_dispatch.request.path_parameters" => { :format => "json" }
+ )
+
+ @user.stubs(:can?).with(:edit_vulnerability).returns(true)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/vulnerability/v1/cves/business_risk', 'test_controller', @user, @organization, @location)
+ end
+
+ # PATCH /api/vulnerability/v1/systems/opt_out requires edit_vulnerability
+ test 'should allow PATCH request to systems opt_out when user has edit_vulnerability permission' do
+ patch_data = '{"opt_out": true}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/vulnerability/v1/systems/opt_out',
+ 'REQUEST_METHOD' => 'PATCH',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => patch_data,
+ "action_dispatch.request.path_parameters" => { :format => "json" }
+ )
+
+ @user.stubs(:can?).with(:edit_vulnerability).returns(true)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/vulnerability/v1/systems/opt_out', 'test_controller', @user, @organization, @location)
+ end
+
+ # Unprotected endpoints (no permission required)
+ test 'should allow GET requests to vulnerability dashbar without permission checks' do
+ user_agent = { :foo => :bar }
+ params = {}
+
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/vulnerability/v1/dashbar',
+ 'REQUEST_METHOD' => 'GET',
+ 'HTTP_USER_AGENT' => user_agent,
+ 'rack.input' => ::Puma::NullIO.new,
+ 'action_dispatch.request.query_parameters' => params
+ )
+
+ ::ForemanRhCloud::TagsAuth.any_instance.expects(:update_tag)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/vulnerability/v1/dashbar', 'test_controller', @user, @organization, @location)
+ end
+
+ # Helper method tests
+ test 'required_permission_for should return view_vulnerability for inventory hosts GET' do
+ permission = @forwarder.send(:required_permission_for, 'api/inventory/v1/hosts', 'GET')
+ assert_equal :view_vulnerability, permission
+ end
+
+ test 'required_permission_for should return view_vulnerability for vulnerabilities cves POST' do
+ permission = @forwarder.send(:required_permission_for, 'api/vulnerability/v1/vulnerabilities/cves', 'POST')
+ assert_equal :view_vulnerability, permission
+ end
+
+ test 'required_permission_for should return edit_vulnerability for status PATCH' do
+ permission = @forwarder.send(:required_permission_for, 'api/vulnerability/v1/status', 'PATCH')
+ assert_equal :edit_vulnerability, permission
+ end
+
+ test 'required_permission_for should return edit_vulnerability for cves status PATCH' do
+ permission = @forwarder.send(:required_permission_for, 'api/vulnerability/v1/cves/status', 'PATCH')
+ assert_equal :edit_vulnerability, permission
+ end
+
+ test 'required_permission_for should return edit_vulnerability for cves business_risk PATCH' do
+ permission = @forwarder.send(:required_permission_for, 'api/vulnerability/v1/cves/business_risk', 'PATCH')
+ assert_equal :edit_vulnerability, permission
+ end
+
+ test 'required_permission_for should return edit_vulnerability for systems opt_out PATCH' do
+ permission = @forwarder.send(:required_permission_for, 'api/vulnerability/v1/systems/opt_out', 'PATCH')
+ assert_equal :edit_vulnerability, permission
+ end
+
+ test 'required_permission_for should return nil for unprotected endpoint' do
+ permission = @forwarder.send(:required_permission_for, 'api/vulnerability/v1/dashbar', 'GET')
+ assert_nil permission
+ end
+
+ test 'required_permission_for should return nil for unknown endpoint' do
+ permission = @forwarder.send(:required_permission_for, 'api/unknown/endpoint', 'GET')
+ assert_nil permission
+ end
+
+ # Advisor permission tests
+
+ # GET /api/insights/v1/* requires view_advisor
+ test 'should allow GET request to insights endpoint when user has view_advisor permission' do
+ user_agent = { :foo => :bar }
+ params = {}
+
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/stats/systems',
+ 'REQUEST_METHOD' => 'GET',
+ 'HTTP_USER_AGENT' => user_agent,
+ 'rack.input' => ::Puma::NullIO.new,
+ 'action_dispatch.request.query_parameters' => params
+ )
+
+ @user.stubs(:can?).with(:view_advisor).returns(true)
+ ::ForemanRhCloud::TagsAuth.any_instance.expects(:update_tag)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/insights/v1/stats/systems', 'test_controller', @user, @organization, @location)
+ end
+
+ test 'should deny GET request to insights endpoint when user lacks view_advisor permission' do
+ user_agent = { :foo => :bar }
+ params = {}
+
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/stats/systems',
+ 'REQUEST_METHOD' => 'GET',
+ 'HTTP_USER_AGENT' => user_agent,
+ 'rack.input' => ::Puma::NullIO.new,
+ 'action_dispatch.request.query_parameters' => params
+ )
+
+ @user.stubs(:can?).with(:view_advisor).returns(false)
+
+ assert_raises(::Foreman::Exception) do
+ @forwarder.forward_request(req, 'api/insights/v1/stats/systems', 'test_controller', @user, @organization, @location)
+ end
+ end
+
+ # POST /api/insights/v1/ack/ requires edit_advisor
+ test 'should allow POST request to insights ack when user has edit_advisor permission' do
+ post_data = '{"rule_id": "test|RULE"}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/ack/',
+ 'REQUEST_METHOD' => 'POST',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => post_data
+ )
+
+ @user.stubs(:can?).with(:edit_advisor).returns(true)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/insights/v1/ack/', 'test_controller', @user, @organization, @location)
+ end
+
+ test 'should deny POST request to insights ack when user lacks edit_advisor permission' do
+ post_data = '{"rule_id": "test|RULE"}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/ack/',
+ 'REQUEST_METHOD' => 'POST',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => post_data
+ )
+
+ @user.stubs(:can?).with(:edit_advisor).returns(false)
+
+ assert_raises(::Foreman::Exception) do
+ @forwarder.forward_request(req, 'api/insights/v1/ack/', 'test_controller', @user, @organization, @location)
+ end
+ end
+
+ # DELETE /api/insights/v1/ack/{rule_id}/ requires edit_advisor
+ test 'should allow DELETE request to insights ack rule when user has edit_advisor permission' do
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/ack/test_rule_id',
+ 'REQUEST_METHOD' => 'DELETE',
+ 'rack.input' => ::Puma::NullIO.new
+ )
+
+ @user.stubs(:can?).with(:edit_advisor).returns(true)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/insights/v1/ack/test_rule_id', 'test_controller', @user, @organization, @location)
+ end
+
+ test 'should deny DELETE request to insights ack rule when user lacks edit_advisor permission' do
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/ack/test_rule_id',
+ 'REQUEST_METHOD' => 'DELETE',
+ 'rack.input' => ::Puma::NullIO.new
+ )
+
+ @user.stubs(:can?).with(:edit_advisor).returns(false)
+
+ assert_raises(::Foreman::Exception) do
+ @forwarder.forward_request(req, 'api/insights/v1/ack/test_rule_id', 'test_controller', @user, @organization, @location)
+ end
+ end
+
+ # POST /api/insights/v1/hostack/ requires edit_advisor
+ test 'should allow POST request to insights hostack when user has edit_advisor permission' do
+ post_data = '{"host_id": "test-uuid", "rule_id": "test|RULE"}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/hostack/',
+ 'REQUEST_METHOD' => 'POST',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => post_data
+ )
+
+ @user.stubs(:can?).with(:edit_advisor).returns(true)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/insights/v1/hostack/', 'test_controller', @user, @organization, @location)
+ end
+
+ test 'should deny POST request to insights hostack when user lacks edit_advisor permission' do
+ post_data = '{"host_id": "test-uuid", "rule_id": "test|RULE"}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/hostack/',
+ 'REQUEST_METHOD' => 'POST',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => post_data
+ )
+
+ @user.stubs(:can?).with(:edit_advisor).returns(false)
+
+ assert_raises(::Foreman::Exception) do
+ @forwarder.forward_request(req, 'api/insights/v1/hostack/', 'test_controller', @user, @organization, @location)
+ end
+ end
+
+ # DELETE /api/insights/v1/hostack/{id}/ requires edit_advisor
+ test 'should allow DELETE request to insights hostack id when user has edit_advisor permission' do
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/hostack/12345',
+ 'REQUEST_METHOD' => 'DELETE',
+ 'rack.input' => ::Puma::NullIO.new
+ )
+
+ @user.stubs(:can?).with(:edit_advisor).returns(true)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/insights/v1/hostack/12345', 'test_controller', @user, @organization, @location)
+ end
+
+ test 'should deny DELETE request to insights hostack id when user lacks edit_advisor permission' do
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/hostack/12345',
+ 'REQUEST_METHOD' => 'DELETE',
+ 'rack.input' => ::Puma::NullIO.new
+ )
+
+ @user.stubs(:can?).with(:edit_advisor).returns(false)
+
+ assert_raises(::Foreman::Exception) do
+ @forwarder.forward_request(req, 'api/insights/v1/hostack/12345', 'test_controller', @user, @organization, @location)
+ end
+ end
+
+ # POST /api/insights/v1/rule/{rule_id}/unack_hosts/ requires edit_advisor
+ test 'should allow POST request to rule unack_hosts when user has edit_advisor permission' do
+ post_data = '{"host_ids": ["uuid1", "uuid2"]}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/rule/test_rule/unack_hosts',
+ 'REQUEST_METHOD' => 'POST',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => post_data
+ )
+
+ @user.stubs(:can?).with(:edit_advisor).returns(true)
+ @forwarder.expects(:execute_cloud_request).returns(true)
+
+ @forwarder.forward_request(req, 'api/insights/v1/rule/test_rule/unack_hosts', 'test_controller', @user, @organization, @location)
+ end
+
+ test 'should deny POST request to rule unack_hosts when user lacks edit_advisor permission' do
+ post_data = '{"host_ids": ["uuid1", "uuid2"]}'
+ req = ActionDispatch::Request.new(
+ 'REQUEST_URI' => '/api/insights/v1/rule/test_rule/unack_hosts',
+ 'REQUEST_METHOD' => 'POST',
+ 'rack.input' => ::Puma::NullIO.new,
+ 'RAW_POST_DATA' => post_data
+ )
+
+ @user.stubs(:can?).with(:edit_advisor).returns(false)
+
+ assert_raises(::Foreman::Exception) do
+ @forwarder.forward_request(req, 'api/insights/v1/rule/test_rule/unack_hosts', 'test_controller', @user, @organization, @location)
+ end
+ end
+
+ # Helper method tests for advisor
+ test 'required_permission_for should return view_advisor for insights GET' do
+ permission = @forwarder.send(:required_permission_for, 'api/insights/v1/stats/systems', 'GET')
+ assert_equal :view_advisor, permission
+ end
+
+ test 'required_permission_for should return edit_advisor for ack POST' do
+ permission = @forwarder.send(:required_permission_for, 'api/insights/v1/ack/', 'POST')
+ assert_equal :edit_advisor, permission
+ end
+
+ test 'required_permission_for should return edit_advisor for ack DELETE' do
+ permission = @forwarder.send(:required_permission_for, 'api/insights/v1/ack/rule_id', 'DELETE')
+ assert_equal :edit_advisor, permission
+ end
+
+ test 'required_permission_for should return edit_advisor for hostack POST' do
+ permission = @forwarder.send(:required_permission_for, 'api/insights/v1/hostack/', 'POST')
+ assert_equal :edit_advisor, permission
+ end
+
+ test 'required_permission_for should return edit_advisor for hostack DELETE' do
+ permission = @forwarder.send(:required_permission_for, 'api/insights/v1/hostack/123', 'DELETE')
+ assert_equal :edit_advisor, permission
+ end
+
+ test 'required_permission_for should return edit_advisor for rule unack_hosts POST' do
+ permission = @forwarder.send(:required_permission_for, 'api/insights/v1/rule/test_rule/unack_hosts', 'POST')
+ assert_equal :edit_advisor, permission
+ end
end
diff --git a/webpack/CVEsHostDetailsTab/CVEsHostDetailsTab.js b/webpack/CVEsHostDetailsTab/CVEsHostDetailsTab.js
index 7cb993ce2..d500e3cc2 100644
--- a/webpack/CVEsHostDetailsTab/CVEsHostDetailsTab.js
+++ b/webpack/CVEsHostDetailsTab/CVEsHostDetailsTab.js
@@ -1,7 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ScalprumComponent, ScalprumProvider } from '@scalprum/react-core';
-import { providerOptions } from '../common/ScalprumModule/ScalprumContext';
+import { createProviderOptions } from '../common/ScalprumModule/ScalprumContext';
+import { useInsightsPermissions } from '../common/Hooks/PermissionsHooks';
import './CVEsHostDetailsTab.scss';
const CVEsHostDetailsTab = ({ systemId }) => {
@@ -18,14 +19,17 @@ CVEsHostDetailsTab.propTypes = {
systemId: PropTypes.string.isRequired,
};
-const CVEsHostDetailsTabWrapper = ({ response }) => (
-
-
-
-);
+const CVEsHostDetailsTabWrapper = ({ response }) => {
+ const permissions = useInsightsPermissions();
+ return (
+
+
+
+ );
+};
CVEsHostDetailsTabWrapper.propTypes = {
response: PropTypes.shape({
diff --git a/webpack/CveDetailsPage/CveDetailsPage.js b/webpack/CveDetailsPage/CveDetailsPage.js
index 481d906f4..1dc975267 100644
--- a/webpack/CveDetailsPage/CveDetailsPage.js
+++ b/webpack/CveDetailsPage/CveDetailsPage.js
@@ -1,15 +1,17 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { ScalprumComponent, ScalprumProvider } from '@scalprum/react-core';
-import { providerOptions } from '../common/ScalprumModule/ScalprumContext';
+import { createProviderOptions } from '../common/ScalprumModule/ScalprumContext';
+import { useInsightsPermissions } from '../common/Hooks/PermissionsHooks';
const CveDetailsPage = () => {
const { cveId } = useParams();
+ const permissions = useInsightsPermissions();
const scope = 'vulnerability';
const module = './CveDetailPage';
return (
-
+
diff --git a/webpack/InsightsCloudSync/InsightsCloudSync.js b/webpack/InsightsCloudSync/InsightsCloudSync.js
index 7dbd4d84a..d9bab77f7 100644
--- a/webpack/InsightsCloudSync/InsightsCloudSync.js
+++ b/webpack/InsightsCloudSync/InsightsCloudSync.js
@@ -14,7 +14,8 @@ import './InsightsCloudSync.scss';
import Pagination from './Components/InsightsTable/Pagination';
import ToolbarDropdown from './Components/ToolbarDropdown';
import InsightsSettings from './Components/InsightsSettings';
-import { providerOptions } from '../common/ScalprumModule/ScalprumContext';
+import { createProviderOptions } from '../common/ScalprumModule/ScalprumContext';
+import { useInsightsPermissions } from '../common/Hooks/PermissionsHooks';
// Hosted Insights advisor
const InsightsCloudSync = ({ syncInsights, query, fetchInsights }) => {
@@ -78,11 +79,14 @@ const IopRecommendationsPage = props => (
);
-const IopRecommendationsPageWrapped = props => (
-
-
-
-);
+const IopRecommendationsPageWrapped = props => {
+ const permissions = useInsightsPermissions();
+ return (
+
+
+
+ );
+};
const RecommendationsPage = props => {
const isIop = useIopConfig();
diff --git a/webpack/InsightsHostDetailsTab/NewHostDetailsTab.js b/webpack/InsightsHostDetailsTab/NewHostDetailsTab.js
index 6603eae8f..ef28a2017 100644
--- a/webpack/InsightsHostDetailsTab/NewHostDetailsTab.js
+++ b/webpack/InsightsHostDetailsTab/NewHostDetailsTab.js
@@ -23,7 +23,8 @@ import {
import { redHatAdvisorSystems } from '../InsightsCloudSync/InsightsCloudSyncHelpers';
import { useIopConfig } from '../common/Hooks/ConfigHooks';
import { generateRuleUrl } from '../InsightsCloudSync/InsightsCloudSync';
-import { providerOptions } from '../common/ScalprumModule/ScalprumContext';
+import { createProviderOptions } from '../common/ScalprumModule/ScalprumContext';
+import { useInsightsPermissions } from '../common/Hooks/PermissionsHooks';
// Hosted Insights advisor
const NewHostDetailsTab = ({ hostName, router }) => {
@@ -124,11 +125,14 @@ const IopInsightsTab = props => (
);
-const IopInsightsTabWrapped = props => (
-
-
-
-);
+const IopInsightsTabWrapped = props => {
+ const permissions = useInsightsPermissions();
+ return (
+
+
+
+ );
+};
const InsightsTab = props => {
const isIop = useIopConfig();
diff --git a/webpack/InsightsVulnerability/InsightsVulnerabilityListPage.js b/webpack/InsightsVulnerability/InsightsVulnerabilityListPage.js
index b182b8c42..21f4fc502 100644
--- a/webpack/InsightsVulnerability/InsightsVulnerabilityListPage.js
+++ b/webpack/InsightsVulnerability/InsightsVulnerabilityListPage.js
@@ -1,6 +1,7 @@
import React from 'react';
import { ScalprumComponent, ScalprumProvider } from '@scalprum/react-core';
-import { providerOptions } from '../common/ScalprumModule/ScalprumContext';
+import { createProviderOptions } from '../common/ScalprumModule/ScalprumContext';
+import { useInsightsPermissions } from '../common/Hooks/PermissionsHooks';
const InsightsVulnerabilityListPage = () => {
const scope = 'vulnerability';
@@ -12,10 +13,13 @@ const InsightsVulnerabilityListPage = () => {
);
};
-const InsightsVulnerabilityListPageWrap = () => (
-
-
-
-);
+const InsightsVulnerabilityListPageWrap = () => {
+ const permissions = useInsightsPermissions();
+ return (
+
+
+
+ );
+};
export default InsightsVulnerabilityListPageWrap;
diff --git a/webpack/IopRecommendationDetails/IopRecommendationDetails.js b/webpack/IopRecommendationDetails/IopRecommendationDetails.js
index b9089d9a3..e710f2f36 100644
--- a/webpack/IopRecommendationDetails/IopRecommendationDetails.js
+++ b/webpack/IopRecommendationDetails/IopRecommendationDetails.js
@@ -3,7 +3,8 @@ import { useRouteMatch } from 'react-router-dom';
import { ScalprumComponent, ScalprumProvider } from '@scalprum/react-core';
import RemediationModal from '../InsightsCloudSync/Components/RemediationModal';
-import { providerOptions } from '../common/ScalprumModule/ScalprumContext';
+import { createProviderOptions } from '../common/ScalprumModule/ScalprumContext';
+import { useInsightsPermissions } from '../common/Hooks/PermissionsHooks';
const scope = 'advisor';
const module = './RecommendationDetailsWrapped';
@@ -25,10 +26,13 @@ const IopRecommendationDetails = props => {
);
};
-const IopRecommendationDetailsWrapped = props => (
-
-
-
-);
+const IopRecommendationDetailsWrapped = props => {
+ const permissions = useInsightsPermissions();
+ return (
+
+
+
+ );
+};
export default IopRecommendationDetailsWrapped;
diff --git a/webpack/common/Hooks/PermissionsHooks.js b/webpack/common/Hooks/PermissionsHooks.js
new file mode 100644
index 000000000..c6be24e54
--- /dev/null
+++ b/webpack/common/Hooks/PermissionsHooks.js
@@ -0,0 +1,58 @@
+import { useMemo } from 'react';
+import { useForemanContext } from 'foremanReact/Root/Context/ForemanContext';
+
+/**
+ * Mapping from Foreman permissions to Insights Chrome API permissions.
+ * Used to convert Foreman's permission names to the format expected by
+ * Scalprum-loaded Insights apps (vulnerability-ui, advisor).
+ *
+ * When adding new permissions, update both:
+ * - This mapping (for frontend RBAC in Scalprum apps)
+ * - The SCOPED_REQUESTS in app/services/foreman_rh_cloud/insights_api_forwarder.rb (for backend API enforcement)
+ */
+const PERMISSION_MAPPING = {
+ view_vulnerability: [
+ 'inventory:hosts:read',
+ 'vulnerability:vulnerability_results:read',
+ 'vulnerability:system.opt_out:read',
+ 'vulnerability:report_and_export:read',
+ 'vulnerability:advanced_report:read',
+ ],
+ edit_vulnerability: [
+ 'vulnerability:system.cve.status:write',
+ 'vulnerability:cve.business_risk_and_status:write',
+ 'vulnerability:system.opt_out:write',
+ ],
+ view_advisor: ['advisor:recommendation-results:read', 'advisor:exports:read'],
+ edit_advisor: ['advisor:disable-recommendations:write'],
+};
+
+/**
+ * Hook to access Insights permissions in Chrome API format.
+ * Reads Foreman permissions from context and converts them to the format
+ * expected by Scalprum-loaded Insights apps.
+ *
+ * Uses ForemanContext.metadata.permissions (added in Foreman PR #10338).
+ * Falls back to empty permissions if not available (older Foreman versions).
+ *
+ * @see https://github.com/theforeman/foreman/blob/develop/developer_docs/handling_user_permissions.asciidoc
+ * @returns {Array<{permission: string, resourceDefinitions: Array}>} User's Insights permissions
+ */
+export const useInsightsPermissions = () => {
+ const context = useForemanContext();
+ const userPermissions = context?.metadata?.permissions || new Set();
+
+ return useMemo(
+ () =>
+ Object.entries(PERMISSION_MAPPING).flatMap(
+ ([foremanPerm, insightsPerms]) =>
+ userPermissions.has(foremanPerm)
+ ? insightsPerms.map(perm => ({
+ permission: perm,
+ resourceDefinitions: [],
+ }))
+ : []
+ ),
+ [userPermissions]
+ );
+};
diff --git a/webpack/common/ScalprumModule/ScalprumContext.js b/webpack/common/ScalprumModule/ScalprumContext.js
index 0e1863ba7..469669d0c 100644
--- a/webpack/common/ScalprumModule/ScalprumContext.js
+++ b/webpack/common/ScalprumModule/ScalprumContext.js
@@ -39,7 +39,25 @@ export const mockUser = {
},
};
-export const providerOptions = {
+/**
+ * Creates getUserPermissions function for Chrome API.
+ * @param {Array} permissions - Permissions array from ForemanContext
+ * @returns {Function} getUserPermissions function
+ */
+const createGetUserPermissions = permissions => async (app, _bypassCache) =>
+ app
+ ? permissions.filter(p => p.permission?.startsWith(`${app}:`))
+ : permissions;
+
+/**
+ * Creates provider options with the given permissions.
+ * Call this from wrapper components with permissions from useInsightsPermissions().
+ *
+ * @see https://github.com/theforeman/foreman/blob/develop/developer_docs/foreman-context.asciidoc
+ * @param {Array} permissions - Permissions from useInsightsPermissions()
+ * @returns {Object} Provider options for ScalprumProvider
+ */
+export const createProviderOptions = (permissions = []) => ({
pluginSDKOptions: {
pluginLoaderOptions: {
transformPluginManifest: manifest => {
@@ -66,8 +84,9 @@ export const providerOptions = {
on: () => {},
auth: {
getUser: () => Promise.resolve(mockUser),
+ getUserPermissions: createGetUserPermissions(permissions),
},
},
},
config: modulesConfig,
-};
+});
diff --git a/webpack/common/ScalprumModule/__tests__/ScalprumContext.test.js b/webpack/common/ScalprumModule/__tests__/ScalprumContext.test.js
new file mode 100644
index 000000000..6e70c0f7f
--- /dev/null
+++ b/webpack/common/ScalprumModule/__tests__/ScalprumContext.test.js
@@ -0,0 +1,120 @@
+import {
+ modulesConfig,
+ mockUser,
+ createProviderOptions,
+} from '../ScalprumContext';
+
+describe('ScalprumContext', () => {
+ describe('modulesConfig', () => {
+ it('should have vulnerability module config', () => {
+ expect(modulesConfig.vulnerability).toBeDefined();
+ expect(modulesConfig.vulnerability.name).toBe('vulnerability');
+ });
+
+ it('should have advisor module config', () => {
+ expect(modulesConfig.advisor).toBeDefined();
+ expect(modulesConfig.advisor.name).toBe('advisor');
+ });
+
+ it('should have inventory module config', () => {
+ expect(modulesConfig.inventory).toBeDefined();
+ expect(modulesConfig.inventory.name).toBe('inventory');
+ });
+ });
+
+ describe('mockUser', () => {
+ it('should have identity with org_id FOREMAN', () => {
+ expect(mockUser.identity.org_id).toBe('FOREMAN');
+ });
+ });
+
+ describe('createProviderOptions', () => {
+ it('should have isBeta function', () => {
+ const options = createProviderOptions([]);
+ expect(options.api.chrome.isBeta).toBeInstanceOf(Function);
+ expect(options.api.chrome.isBeta()).toBe(false);
+ });
+
+ it('should have auth.getUser that resolves to mockUser', async () => {
+ const options = createProviderOptions([]);
+ expect(options.api.chrome.auth.getUser).toBeInstanceOf(Function);
+ const user = await options.api.chrome.auth.getUser();
+ expect(user).toEqual(mockUser);
+ });
+
+ it('should return empty array when no permissions provided', async () => {
+ const options = createProviderOptions([]);
+ const permissions = await options.api.chrome.auth.getUserPermissions();
+ expect(permissions).toEqual([]);
+ });
+
+ describe('getUserPermissions', () => {
+ const testPermissions = [
+ { permission: 'inventory:hosts:read', resourceDefinitions: [] },
+ {
+ permission: 'vulnerability:vulnerability_results:read',
+ resourceDefinitions: [],
+ },
+ {
+ permission: 'vulnerability:system.opt_out:read',
+ resourceDefinitions: [],
+ },
+ ];
+
+ it('should filter vulnerability permissions by app prefix', async () => {
+ const options = createProviderOptions(testPermissions);
+ const permissions = await options.api.chrome.auth.getUserPermissions(
+ 'vulnerability'
+ );
+
+ expect(permissions).toHaveLength(2);
+ expect(permissions[0].permission).toBe(
+ 'vulnerability:vulnerability_results:read'
+ );
+ });
+
+ it('should filter inventory permissions by app prefix', async () => {
+ const options = createProviderOptions(testPermissions);
+ const permissions = await options.api.chrome.auth.getUserPermissions(
+ 'inventory'
+ );
+
+ expect(permissions).toHaveLength(1);
+ expect(permissions[0].permission).toBe('inventory:hosts:read');
+ });
+
+ it('should return all permissions when no app filter is provided', async () => {
+ const options = createProviderOptions(testPermissions);
+ const permissions = await options.api.chrome.auth.getUserPermissions();
+
+ expect(permissions).toHaveLength(3);
+ });
+
+ it('should return permissions in Chrome API format', async () => {
+ const options = createProviderOptions(testPermissions);
+ const permissions = await options.api.chrome.auth.getUserPermissions(
+ 'vulnerability'
+ );
+
+ expect(permissions[0]).toHaveProperty('permission');
+ expect(permissions[0]).toHaveProperty('resourceDefinitions');
+ expect(Array.isArray(permissions[0].resourceDefinitions)).toBe(true);
+ });
+
+ it('should handle permissions with missing permission field', async () => {
+ const malformedPermissions = [
+ { permission: 'inventory:hosts:read', resourceDefinitions: [] },
+ { resourceDefinitions: [] }, // missing permission field
+ { permission: null, resourceDefinitions: [] },
+ ];
+ const options = createProviderOptions(malformedPermissions);
+
+ const permissions = await options.api.chrome.auth.getUserPermissions(
+ 'inventory'
+ );
+ expect(permissions).toHaveLength(1);
+ expect(permissions[0].permission).toBe('inventory:hosts:read');
+ });
+ });
+ });
+});