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'); + }); + }); + }); +});