From beb95992d62a2bd38da584d4e559a039b71f1565 Mon Sep 17 00:00:00 2001 From: Bernhard Suttner Date: Mon, 22 Jun 2026 20:49:03 +0200 Subject: [PATCH 1/2] Fixes #39447 - Guard host bulk actions with scope hash Compute a scope hash from the current user, current taxonomy context, and all currently accessible host ids of the user, expose it to the new hosts page, and require it on bulk actions. The backend now rejects those actions with 409 when the hash is missing or stale, which fails closed on cross-tab taxonomy changes and host set drift. Co-authored-by: OpenAI Codex --- .../api/v2/hosts_bulk_actions_controller.rb | 55 +++++- app/controllers/api/v2/hosts_controller.rb | 9 +- app/services/hosts/bulk_scope_hash.rb | 34 ++++ config/initializers/f_foreman_permissions.rb | 2 +- config/routes/api/v2.rb | 1 + .../v2/hosts_bulk_actions_controller_test.rb | 187 ++++++++++++++++++ .../api/v2/hosts_controller_test.rb | 10 + .../BulkActions/__tests__/bulkDelete.test.js | 7 + .../assignTaxonomy/BulkAssignTaxonomyModal.js | 4 + .../BulkActions/assignTaxonomy/index.js | 4 + .../buildHosts/BulkBuildHostModal.js | 4 + .../BulkActions/buildHosts/index.js | 2 + .../HostsIndex/BulkActions/bulkDelete.js | 2 + .../changeOwner/BulkChangeOwnerModal.js | 4 + .../BulkActions/changeOwner/index.js | 2 + .../disassociate/BulkDisassociateModal.js | 4 + .../BulkActions/disassociate/index.js | 2 + .../HostsIndex/BulkActions/helpers.js | 2 + .../powerState/BulkPowerStateModal.js | 4 + .../BulkActions/powerState/index.js | 2 + .../BulkReassignHostgroupModal.js | 4 + .../BulkActions/reassignHostGroup/index.js | 2 + .../react_app/components/HostsIndex/index.js | 8 + .../components/HostsIndex/index.test.js | 18 ++ 24 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 app/services/hosts/bulk_scope_hash.rb diff --git a/app/controllers/api/v2/hosts_bulk_actions_controller.rb b/app/controllers/api/v2/hosts_bulk_actions_controller.rb index 0c7bce1830..d2faba567d 100644 --- a/app/controllers/api/v2/hosts_bulk_actions_controller.rb +++ b/app/controllers/api/v2/hosts_bulk_actions_controller.rb @@ -4,11 +4,17 @@ class HostsBulkActionsController < V2::BaseController include Api::Version2 include Api::V2::BulkHostsExtension + before_action :validate_scope_hash!, + :only => [:bulk_destroy, :build, :reassign_hostgroup, :change_owner, + :disassociate, :change_power_state, :assign_organization, + :assign_location] before_action :find_deletable_hosts, :only => [:bulk_destroy] before_action :find_editable_hosts, :only => [:build, :reassign_hostgroup, :change_owner, :disassociate, :change_power_state, :manage_notifications] before_action :validate_power_action, :only => [:change_power_state] def_param_group :bulk_host_ids do + param :scope_hash, String, :required => false, :desc => N_("Fingerprint of the visible host scope when the bulk action was initiated") + param :skip_scope_hash, :bool, :required => false, :desc => N_("Skip validation of the visible host scope fingerprint for direct API usage") param :included, Hash, :desc => N_("Hosts to include in the action"), :required => true, :action_aware => true do param :search, String, :required => false, :desc => N_("Search string describing which hosts to perform the action on") param :ids, Array, :required => false, :desc => N_("List of host ids to perform the action on") @@ -23,7 +29,12 @@ class HostsBulkActionsController < V2::BaseController api :DELETE, "/hosts/bulk/", N_("Delete multiple hosts") param_group :bulk_host_ids def bulk_destroy - process_response @hosts.destroy_all + deleted_hosts_count = @hosts.count + destroy_result = @hosts.destroy_all + + process_response(destroy_result, { + :message => n_("%s host deleted", "%s hosts deleted", deleted_hosts_count) % deleted_hosts_count, + }) end api :PUT, "/hosts/bulk/build", N_("Build") @@ -209,6 +220,48 @@ def action_permission private + def validate_scope_hash! + return if skip_scope_hash? + + if scope_hash_param.blank? + Rails.logger.debug do + "Bulk scope hash missing for #{self.class}##{action_name} " \ + "(user_id=#{User.current&.id}, organization_id=#{Organization.current&.id}, " \ + "location_id=#{Location.current&.id})" + end + + render_error(:custom_error, :status => :conflict, :locals => { + :message => _('The host scope changed or is missing integrity data. Refresh and retry the bulk action.'), + }) + return + end + + current_scope_hash = ::Hosts::BulkScopeHash.for_current_viewer + return if ActiveSupport::SecurityUtils.secure_compare(scope_hash_param, current_scope_hash) + + Rails.logger.debug do + "Bulk scope hash mismatch for #{self.class}##{action_name} " \ + "(user_id=#{User.current&.id}, organization_id=#{Organization.current&.id}, " \ + "location_id=#{Location.current&.id}, received_scope_hash=#{scope_hash_param.inspect}, " \ + "current_scope_hash=#{current_scope_hash.inspect})" + end + + render_error(:custom_error, :status => :conflict, :locals => { + :message => _('The host scope changed or the organization/location context was modified in another tab. Refresh and retry the bulk action.'), + }) + end + + def scope_hash_param + params[:scope_hash].presence || params.dig(:hosts_bulk_action, :scope_hash).presence + end + + def skip_scope_hash? + Foreman::Cast.to_bool( + params[:skip_scope_hash].presence || + params.dig(:hosts_bulk_action, :skip_scope_hash).presence + ) + end + def find_deletable_hosts find_bulk_hosts(:destroy_hosts, included: params) end diff --git a/app/controllers/api/v2/hosts_controller.rb b/app/controllers/api/v2/hosts_controller.rb index e3071164b6..a5becc3c4d 100644 --- a/app/controllers/api/v2/hosts_controller.rb +++ b/app/controllers/api/v2/hosts_controller.rb @@ -12,7 +12,7 @@ class HostsController < V2::BaseController include HostsControllerExtension before_action :find_optional_nested_object, :except => [:facts] - before_action :find_resource, :except => [:index, :create, :facts] + before_action :find_resource, :except => [:index, :create, :facts, :bulk_scope_hash] check_permissions_for %w{power boot} before_action :process_parameter_attributes, :only => %w{update} @@ -26,6 +26,7 @@ class HostsController < V2::BaseController end api :GET, "/hosts/", N_("List all hosts") + api :GET, "/hosts/bulk_scope_hash", N_("Get hash of the current visible host scope") api :GET, "/hostgroups/:hostgroup_id/hosts", N_("List all hosts for a host group") api :GET, "/locations/:location_id/hosts", N_("List hosts per location") api :GET, "/organizations/:organization_id/hosts", N_("List hosts per organization") @@ -60,6 +61,10 @@ def index end end + def bulk_scope_hash + render :json => { :scope_hash => ::Hosts::BulkScopeHash.for_current_viewer } + end + api :GET, "/hosts/:id/", N_("Show a host") param :id, :identifier_dottable, :required => true param :show_hidden_parameters, :bool, :desc => N_("Display hidden parameter values") @@ -377,6 +382,8 @@ def interface_attributes(params) def action_permission case params[:action] + when 'bulk_scope_hash' + :view when 'power_status' :power when 'power' diff --git a/app/services/hosts/bulk_scope_hash.rb b/app/services/hosts/bulk_scope_hash.rb new file mode 100644 index 0000000000..ffbb337ead --- /dev/null +++ b/app/services/hosts/bulk_scope_hash.rb @@ -0,0 +1,34 @@ +require 'digest' + +module Hosts + class BulkScopeHash + def self.for_current_viewer + new( + user: User.current, + organization: Organization.current, + location: Location.current + ).to_s + end + + def initialize(user:, organization:, location:) + @user = user + @organization = organization + @location = location + end + + def to_s + digest = Digest::SHA256.new + digest << "user:#{@user&.id}|org:#{@organization&.id}|loc:#{@location&.id}|" + + hosts_scope.pluck(:id).each { |id| digest << "#{id}," } + + digest.hexdigest + end + + private + + def hosts_scope + ::Host::Managed.authorized(:view_hosts).reselect(:id).reorder(:id) + end + end +end diff --git a/config/initializers/f_foreman_permissions.rb b/config/initializers/f_foreman_permissions.rb index bb4eb07b31..2c473a2faf 100644 --- a/config/initializers/f_foreman_permissions.rb +++ b/config/initializers/f_foreman_permissions.rb @@ -234,7 +234,7 @@ :templates, :overview, :nics, :get_power_state, :preview_host_collection, :welcome, :statuses], :dashboard => [:OutOfSync, :errors, :active], :unattended => [:host_template, :hostgroup_template], - :"api/v2/hosts" => [:index, :show, :get_status, :vm_compute_attributes, :template, :templates, :enc, :inherited_parameters], + :"api/v2/hosts" => [:index, :show, :get_status, :vm_compute_attributes, :template, :templates, :enc, :inherited_parameters, :bulk_scope_hash], :"api/v2/interfaces" => [:index, :show], :"api/v2/host_statuses" => [:index], :locations => [:mismatches], diff --git a/config/routes/api/v2.rb b/config/routes/api/v2.rb index 519d065b37..712168b480 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -4,6 +4,7 @@ # new v2 routes that point to v2 scope "(:apiv)", :module => :v2, :defaults => {:apiv => 'v2'}, :apiv => /v2/, :constraints => ApiConstraints.new(:version => 2, :default => true) do match 'hosts/bulk', :to => 'hosts_bulk_actions#bulk_destroy', :via => [:delete] + get 'hosts/bulk_scope_hash', :to => 'hosts#bulk_scope_hash' put 'hosts/bulk/assign_organization', :to => 'hosts_bulk_actions#assign_organization' put 'hosts/bulk/assign_location', :to => 'hosts_bulk_actions#assign_location' match 'hosts/bulk/build', :to => 'hosts_bulk_actions#build', :via => [:put] diff --git a/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb b/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb index f03af11fd5..68b8e9329f 100644 --- a/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb +++ b/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb @@ -12,6 +12,7 @@ def setup @usergroup = FactoryBot.create(:usergroup) @host_ids = [@host1.id, @host2.id, @host3.id] end + ::Hosts::BulkScopeHash.stubs(:for_current_viewer).returns(valid_scope_hash) end def valid_bulk_params(host_ids = @host_ids) @@ -24,6 +25,7 @@ def valid_bulk_params(host_ids = @host_ids) :excluded => { :ids => [], }, + :scope_hash => valid_scope_hash, } end @@ -31,6 +33,14 @@ def valid_power_params(host_ids = @host_ids, action = 'start') valid_bulk_params(host_ids).merge(:power => action) end + def valid_bulk_destroy_params(host_ids = @host_ids) + { + :organization_id => @organization.id, + :ids => host_ids, + :scope_hash => valid_scope_hash, + } + end + test "should change owner with user id" do put :change_owner, params: valid_bulk_params.merge(:owner_id => @user.id_and_type) @@ -115,6 +125,7 @@ def valid_power_params(host_ids = @host_ids, action = 'start') put :change_owner, params: { :organization_id => @organization.id, :location_id => @location.id, + :scope_hash => valid_scope_hash, :included => { :search => 'name ~ *', }, @@ -258,8 +269,184 @@ def valid_power_params(host_ids = @host_ids, action = 'start') end end + context "bulk_destroy" do + test "successfully deletes all selected hosts" do + assert_difference('Host.count', -@host_ids.size) do + delete :bulk_destroy, params: valid_bulk_destroy_params + end + + assert_response :success + end + end + + context "build" do + test "successfully builds all selected hosts" do + bulk_manager = mock('BulkHostsManager') + BulkHostsManager.expects(:new).with(hosts: anything).returns(bulk_manager) + bulk_manager.expects(:build).with(reboot: false).returns([]) + + put :build, params: valid_bulk_params + + assert_response :success + body = ActiveSupport::JSON.decode(@response.body) + assert_match(/Built 3 hosts/, body['message']) + end + end + + context "disassociate" do + test "successfully disassociates selected hosts" do + bulk_manager = mock('BulkHostsManager') + BulkHostsManager.expects(:new).with(hosts: anything).returns(bulk_manager) + bulk_manager.expects(:disassociate) + + put :disassociate, params: valid_bulk_params + + assert_response :success + body = ActiveSupport::JSON.decode(@response.body) + assert_match(/Updated hosts: Disassociated from compute resource/, body['message']) + end + end + + context "scope_hash validation" do + test "allows bulk destroy without scope hash when skip_scope_hash is true" do + assert_difference('Host.count', -@host_ids.size) do + delete :bulk_destroy, + params: valid_bulk_destroy_params.except(:scope_hash).merge(:skip_scope_hash => true) + end + + assert_response :success + end + + test "rejects bulk destroy when scope hash is missing" do + assert_scope_hash_conflict( + :delete, + :bulk_destroy, + valid_bulk_destroy_params.except(:scope_hash) + ) + end + + test "rejects bulk destroy when scope hash mismatches" do + assert_scope_hash_conflict( + :delete, + :bulk_destroy, + valid_bulk_destroy_params.merge(:scope_hash => 'stale-scope-hash') + ) + end + + test "rejects build when scope hash is missing" do + assert_scope_hash_conflict(:put, :build, valid_bulk_params.except(:scope_hash)) + end + + test "allows build without scope hash when skip_scope_hash is true" do + bulk_manager = mock('BulkHostsManager') + BulkHostsManager.expects(:new).with(hosts: anything).returns(bulk_manager) + bulk_manager.expects(:build).with(reboot: false).returns([]) + + put :build, + params: valid_bulk_params.except(:scope_hash).merge(:skip_scope_hash => true) + + assert_response :success + end + + test "rejects build when scope hash mismatches" do + assert_scope_hash_conflict(:put, :build, valid_bulk_params.merge(:scope_hash => 'stale-scope-hash')) + end + + test "rejects hostgroup reassignment when scope hash is missing" do + assert_scope_hash_conflict( + :put, + :reassign_hostgroup, + valid_bulk_params.except(:scope_hash).merge(:hostgroup_id => 1) + ) + end + + test "rejects hostgroup reassignment when scope hash mismatches" do + assert_scope_hash_conflict( + :put, + :reassign_hostgroup, + valid_bulk_params.merge(:hostgroup_id => 1, :scope_hash => 'stale-scope-hash') + ) + end + + test "rejects owner change when scope hash is missing" do + assert_scope_hash_conflict( + :put, + :change_owner, + valid_bulk_params.except(:scope_hash).merge(:owner_id => @user.id_and_type) + ) + end + + test "rejects owner change when scope hash mismatches" do + assert_scope_hash_conflict( + :put, + :change_owner, + valid_bulk_params.merge(:owner_id => @user.id_and_type, :scope_hash => 'stale-scope-hash') + ) + end + + test "rejects disassociate when scope hash is missing" do + assert_scope_hash_conflict(:put, :disassociate, valid_bulk_params.except(:scope_hash)) + end + + test "rejects disassociate when scope hash mismatches" do + assert_scope_hash_conflict(:put, :disassociate, valid_bulk_params.merge(:scope_hash => 'stale-scope-hash')) + end + + test "rejects organization assignment when scope hash is missing" do + assert_scope_hash_conflict( + :put, + :assign_organization, + valid_bulk_params.except(:scope_hash).merge(:id => @organization.id, :mismatch_setting => true) + ) + end + + test "rejects organization assignment when scope hash mismatches" do + assert_scope_hash_conflict( + :put, + :assign_organization, + valid_bulk_params.merge(:id => @organization.id, :mismatch_setting => true, :scope_hash => 'stale-scope-hash') + ) + end + + test "rejects location assignment when scope hash is missing" do + assert_scope_hash_conflict( + :put, + :assign_location, + valid_bulk_params.except(:scope_hash).merge(:id => @location.id, :mismatch_setting => true) + ) + end + + test "rejects location assignment when scope hash mismatches" do + assert_scope_hash_conflict( + :put, + :assign_location, + valid_bulk_params.merge(:id => @location.id, :mismatch_setting => true, :scope_hash => 'stale-scope-hash') + ) + end + + test "rejects power change when scope hash is missing" do + assert_scope_hash_conflict(:put, :change_power_state, valid_power_params.except(:scope_hash)) + end + + test "rejects power change when scope hash mismatches" do + assert_scope_hash_conflict(:put, :change_power_state, valid_power_params.merge(:scope_hash => 'stale-scope-hash')) + end + end + private + def valid_scope_hash + 'valid-scope-hash' + end + + def assert_scope_hash_conflict(http_method, action, params) + send(http_method, action, params: params) + + assert_response :conflict + body = ActiveSupport::JSON.decode(@response.body) + assert_match(/Refresh and retry the bulk action/, body['error']['message']) + end + def set_session_user { :user => users(:admin).id } end diff --git a/test/controllers/api/v2/hosts_controller_test.rb b/test/controllers/api/v2/hosts_controller_test.rb index ed4b545e9e..8522a02be0 100644 --- a/test/controllers/api/v2/hosts_controller_test.rb +++ b/test/controllers/api/v2/hosts_controller_test.rb @@ -175,6 +175,16 @@ def last_record assert_equal "OK", host_data['global_status_label'] end + test "should get bulk scope hash" do + ::Hosts::BulkScopeHash.stubs(:for_current_viewer).returns('test-scope-hash') + + get :bulk_scope_hash + + assert_response :success + response = ActiveSupport::JSON.decode(@response.body) + assert_equal 'test-scope-hash', response['scope_hash'] + end + test "subtotal should be the same as the search count with thin" do FactoryBot.create_list(:host, 2) Host.last.update_attribute(:name, 'test') diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/__tests__/bulkDelete.test.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/__tests__/bulkDelete.test.js index f132aaffae..5351f9ebec 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/__tests__/bulkDelete.test.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/__tests__/bulkDelete.test.js @@ -39,6 +39,7 @@ describe('bulkDeleteHosts', () => { const bulkParams = 'id ^ (1,2,3)'; const organizationId = 1; const locationId = 2; + const bulkScopeHash = 'test-scope-hash'; const selectedCount = 3; const destroyVmOnHostDelete = true; @@ -47,6 +48,7 @@ describe('bulkDeleteHosts', () => { bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, })(dispatch); @@ -67,6 +69,7 @@ describe('bulkDeleteHosts', () => { bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, })(dispatch); @@ -86,6 +89,7 @@ describe('bulkDeleteHosts', () => { expect(requestUrl.searchParams.get('location_id')).toBe( String(locationId) ); + expect(requestUrl.searchParams.get('scope_hash')).toBe(bulkScopeHash); expect(deleteParams.key).toBe('BULK-HOSTS-DELETE'); expect(typeof deleteParams.successToast).toBe('function'); expect(typeof deleteParams.errorToast).toBe('function'); @@ -102,6 +106,7 @@ describe('bulkDeleteHosts', () => { bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, onDeleteSuccess, @@ -124,6 +129,7 @@ describe('bulkDeleteHosts', () => { bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, })(dispatch); @@ -144,6 +150,7 @@ describe('bulkDeleteHosts', () => { bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, })(dispatch); diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js index 3ae3450866..e8ac566408 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js @@ -55,6 +55,7 @@ const BulkAssignTaxonomyModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, modalType, }) => { const org = modalType === MODAL_TYPES.ORGANIZATION; @@ -143,6 +144,7 @@ const BulkAssignTaxonomyModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, id: taxId, mismatch_setting: fixRadioChecked, }); @@ -248,6 +250,7 @@ BulkAssignTaxonomyModal.propTypes = { fetchBulkParams: PropTypes.func.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, modalType: PropTypes.string.isRequired, }; @@ -256,4 +259,5 @@ BulkAssignTaxonomyModal.defaultProps = { closeModal: () => {}, organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, }; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js index 4375e6b447..c406f78a95 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js @@ -13,6 +13,7 @@ export const BulkAssignOrganizationModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> @@ -35,6 +37,7 @@ export const BulkAssignLocationModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/BulkBuildHostModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/BulkBuildHostModal.js index eea9df3c7e..13b81e4ee9 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/BulkBuildHostModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/BulkBuildHostModal.js @@ -24,6 +24,7 @@ const BulkBuildHostModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, }) => { const dispatch = useDispatch(); const [buildRadioChecked, setBuildRadioChecked] = useState(true); @@ -50,6 +51,7 @@ const BulkBuildHostModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, reboot: rebootChecked, rebuild_configuration: !buildRadioChecked, }); @@ -160,6 +162,7 @@ BulkBuildHostModal.propTypes = { fetchBulkParams: PropTypes.func.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, }; BulkBuildHostModal.defaultProps = { @@ -167,6 +170,7 @@ BulkBuildHostModal.defaultProps = { closeModal: () => {}, organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, }; export default BulkBuildHostModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/index.js index b143c30973..f15ceda1ed 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/index.js @@ -9,6 +9,7 @@ const BulkBuildHostModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js index 5c12e9d7f7..a5d2b820b1 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js @@ -11,6 +11,7 @@ export const bulkDeleteHosts = ({ bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, onDeleteSuccess, @@ -19,6 +20,7 @@ export const bulkDeleteHosts = ({ const errorToast = ({ message }) => message; const queryParams = new URLSearchParams({ search: bulkParams, + ...(bulkScopeHash ? { scope_hash: bulkScopeHash } : {}), ...bulkActionTaxonomyParams({ organizationId, locationId }), }); const url = foremanUrl(`/api/v2/hosts/bulk?${queryParams.toString()}`); diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/BulkChangeOwnerModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/BulkChangeOwnerModal.js index d48e9d9e0c..9c1d391f6d 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/BulkChangeOwnerModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/BulkChangeOwnerModal.js @@ -44,6 +44,7 @@ const BulkChangeOwnerModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, }) => { const dispatch = useDispatch(); const [ownerId, setOwnerId] = useState(''); @@ -137,6 +138,7 @@ const BulkChangeOwnerModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, owner_id: ownerId, }); @@ -254,6 +256,7 @@ BulkChangeOwnerModal.propTypes = { selectAllHostsMode: PropTypes.bool.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, }; BulkChangeOwnerModal.defaultProps = { @@ -261,6 +264,7 @@ BulkChangeOwnerModal.defaultProps = { closeModal: () => {}, organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, }; export default BulkChangeOwnerModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/index.js index 454e56ca50..cd13dd9370 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/index.js @@ -11,6 +11,7 @@ const BulkChangeOwnerModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js index d8b7ec3300..1534610861 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js @@ -29,6 +29,7 @@ const BulkDisassociateModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, }) => { const dispatch = useDispatch(); const hostsWithComputeResource = selectedResults?.filter( @@ -97,6 +98,7 @@ const BulkDisassociateModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, includedSearch: queryString, }); @@ -193,6 +195,7 @@ BulkDisassociateModal.propTypes = { selectAllHostsMode: PropTypes.bool.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, }; BulkDisassociateModal.defaultProps = { @@ -201,6 +204,7 @@ BulkDisassociateModal.defaultProps = { selectedResults: [], organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, }; export default BulkDisassociateModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js index 169eb72f36..f3981ac3b6 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js @@ -11,6 +11,7 @@ const BulkDisassociateModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/helpers.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/helpers.js index e51c1ba8cc..097f1d172f 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/helpers.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/helpers.js @@ -20,6 +20,7 @@ export const buildBulkRequestBody = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, includedSearch, ...params }) => ({ @@ -27,6 +28,7 @@ export const buildBulkRequestBody = ({ search: includedSearch || fetchBulkParams(), }, ...bulkActionTaxonomyParams({ organizationId, locationId }), + ...(bulkScopeHash ? { scope_hash: bulkScopeHash } : {}), ...params, }); diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js index 8cb93e5b10..702f2ad0b8 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js @@ -33,6 +33,7 @@ const BulkPowerStateModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, isOpen, closeModal, }) => { @@ -108,6 +109,7 @@ const BulkPowerStateModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, power: selectedPowerState, }); dispatch(bulkChangePowerState(payload, handleSuccess, handleError)); @@ -210,6 +212,7 @@ BulkPowerStateModal.propTypes = { fetchBulkParams: PropTypes.func.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, isOpen: PropTypes.bool, closeModal: PropTypes.func, }; @@ -218,6 +221,7 @@ BulkPowerStateModal.defaultProps = { selectedHostsCount: 0, organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, isOpen: false, closeModal: () => {}, }; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js index f67e800a9e..e422e5fe00 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js @@ -9,6 +9,7 @@ const BulkPowerStateModalScene = ({ isOpen, closeModal }) => { selectedCount = 0, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/BulkReassignHostgroupModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/BulkReassignHostgroupModal.js index 3e6c051a85..1e1356fe38 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/BulkReassignHostgroupModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/BulkReassignHostgroupModal.js @@ -79,6 +79,7 @@ const BulkReassignHostgroupModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, }) => { const dispatch = useDispatch(); const [hostgroupId, setHostgroupId] = useState(''); @@ -157,6 +158,7 @@ const BulkReassignHostgroupModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, hostgroup_id: hostgroupId, }); @@ -289,6 +291,7 @@ BulkReassignHostgroupModal.propTypes = { fetchBulkParams: PropTypes.func.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, }; BulkReassignHostgroupModal.defaultProps = { @@ -296,6 +299,7 @@ BulkReassignHostgroupModal.defaultProps = { closeModal: () => {}, organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, }; export default BulkReassignHostgroupModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/index.js index d7a4669a73..6961083c84 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/index.js @@ -9,6 +9,7 @@ const BulkReassignHostgroupModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js index f8b82efd1e..d33a39d1db 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -29,6 +29,7 @@ import { translate as __ } from '../../common/I18n'; import TableIndexPage from '../PF4/TableIndexPage/TableIndexPage'; import { ActionKebab } from './ActionKebab'; import { HOSTS_API_PATH, API_REQUEST_KEY } from '../../routes/Hosts/constants'; +import { useAPI } from '../../common/hooks/API/APIHooks'; import { selectKebabItems } from './Selectors'; import { useBulkSelect, @@ -115,9 +116,14 @@ const HostsIndex = () => { defaultParams, syncWithOptions: true, }); + const { response: bulkScopeHashResponse } = useAPI( + 'get', + '/api/v2/hosts/bulk_scope_hash' + ); const contextData = useForemanContext(); const currentOrganization = useForemanOrganization(); const currentLocation = useForemanLocation(); + const bulkScopeHash = bulkScopeHashResponse?.['scope_hash'] || null; const { response: { @@ -246,6 +252,7 @@ const HostsIndex = () => { bulkParams, organizationId: currentOrganization?.id, locationId: currentLocation?.id, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, onDeleteSuccess: () => { @@ -597,6 +604,7 @@ const HostsIndex = () => { fetchBulkParams, organizationId: currentOrganization?.id, locationId: currentLocation?.id, + bulkScopeHash, }} > ({ useForemanLocation: jest.fn(() => undefined), })); +jest.mock('../../common/hooks/API/APIHooks', () => ({ + useAPI: jest.fn(() => ({ + response: { scope_hash: 'test-scope-hash' }, + })), +})); + jest.mock('../common/Slot', () => ({ __esModule: true, default: () => null, @@ -137,6 +144,7 @@ describe('HostsIndex', () => { beforeEach(() => { capturedTableProps = null; + useAPI.mockClear(); }); test('merges API response page and perPage into params passed to Table', () => { @@ -158,6 +166,16 @@ describe('HostsIndex', () => { }); }); + test('requests the bulk scope hash via useAPI', () => { + render( + + + + ); + + expect(useAPI).toHaveBeenCalledWith('get', '/api/v2/hosts/bulk_scope_hash'); + }); + test('returns an explicit all-hosts search for empty select-all queries', () => { expect( getScheduleJobSearch({ From cc90517e54baa0f95e40d7a0c53bb61191c82d7f Mon Sep 17 00:00:00 2001 From: Bernhard Suttner Date: Tue, 23 Jun 2026 13:14:14 +0200 Subject: [PATCH 2/2] Refs #39447 - Use response message of delete bulk action to show error toast Co-authored-by: OpenAI Codex --- .../react_app/components/HostsIndex/BulkActions/bulkDelete.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js index a5d2b820b1..f53ddcf222 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js @@ -17,7 +17,8 @@ export const bulkDeleteHosts = ({ onDeleteSuccess, }) => dispatch => { const successToast = () => sprintf(__('%s hosts deleted'), selectedCount); - const errorToast = ({ message }) => message; + const errorToast = ({ response, message }) => + response?.data?.error?.message || message; const queryParams = new URLSearchParams({ search: bulkParams, ...(bulkScopeHash ? { scope_hash: bulkScopeHash } : {}),