From 2561dae92fae5d28dae5982d7bf582c1f13fff00 Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Thu, 9 Oct 2025 13:19:18 +1200 Subject: [PATCH] Scope invitations by organization when organization scoped --- .../concerns/members/invite_modal_actions.rb | 7 ++- app/finders/members/invite_users_finder.rb | 4 +- app/finders/users_finder.rb | 7 +++ app/models/group.rb | 13 +++-- app/models/project_team.rb | 7 ++- app/models/user.rb | 4 ++ app/services/members/create_service.rb | 3 +- app/services/members/creator_service.rb | 23 +++++++-- db/fixtures/development/03_project.rb | 4 +- db/fixtures/development/06_teams.rb | 4 +- .../concerns/group_invite_members.rb | 5 +- ee/app/controllers/ee/groups_controller.rb | 2 +- ee/db/fixtures/development/22_epics.rb | 6 ++- ee/spec/requests/api/members_spec.rb | 2 +- lib/api/helpers.rb | 3 ++ lib/api/invitations.rb | 7 ++- lib/api/members.rb | 7 ++- locale/gitlab.pot | 3 ++ spec/finders/users_finder_spec.rb | 10 ++++ spec/models/user_spec.rb | 14 ++++++ spec/requests/api/helpers_spec.rb | 10 ++++ .../groups/group_members_controller_spec.rb | 3 ++ spec/services/members/create_service_spec.rb | 28 ++++++++++- spec/services/members/invite_service_spec.rb | 50 ++++++++++++++++++- .../models/member_shared_examples.rb | 39 +++++++++++++++ 25 files changed, 237 insertions(+), 28 deletions(-) diff --git a/app/controllers/concerns/members/invite_modal_actions.rb b/app/controllers/concerns/members/invite_modal_actions.rb index b4c16a5b0d7c79..6f2d7e08e59c41 100644 --- a/app/controllers/concerns/members/invite_modal_actions.rb +++ b/app/controllers/concerns/members/invite_modal_actions.rb @@ -5,7 +5,12 @@ module InviteModalActions extend ActiveSupport::Concern def invite_search - users = Members::InviteUsersFinder.new(current_user, source, search: invite_search_params[:search]).execute + users = Members::InviteUsersFinder.new( + current_user, + source, + organization_id: Current.organization.id, + search: invite_search_params[:search] + ).execute .page(1) .per(invite_search_per_page) diff --git a/app/finders/members/invite_users_finder.rb b/app/finders/members/invite_users_finder.rb index 23fb06953a4180..284d4aa2b22329 100644 --- a/app/finders/members/invite_users_finder.rb +++ b/app/finders/members/invite_users_finder.rb @@ -10,10 +10,10 @@ module Members class InviteUsersFinder < UsersFinder attr_reader :resource - def initialize(current_user, resource, search: nil) + def initialize(current_user, resource, organization_id: nil, search: nil) @current_user = current_user @resource = resource - @params = { search: search } + @params = { search: search, organization_id: organization_id } end def base_scope diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index 52847336405611..27dffd90580ce5 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -58,6 +58,7 @@ def execute users = by_member_source_ids(users) users = by_public_email(users) users = by_user_types(users) + users = by_organization(users) order(users) end @@ -218,6 +219,12 @@ def by_user_types(users) users.with_user_types(params[:user_types]) end + def by_organization(users) + return users unless params[:organization_id].present? + + users.in_organization(params[:organization_id]) + end + def order(users) return users unless params[:sort] diff --git a/app/models/group.rb b/app/models/group.rb index 27fbbfa6adc039..3c98ba4ac328db 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -701,17 +701,22 @@ def owned_by?(user) end def add_members(users, access_level, current_user: nil, expires_at: nil) - Members::Groups::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass + # rubocop:disable CodeReuse/ServiceClass -- Service class contains all the business logic + Members::Groups::CreatorService.add_members( self, users, access_level, current_user: current_user, - expires_at: expires_at + expires_at: expires_at, + organization_id: organization_id ) + # rubocop:enable CodeReuse/ServiceClass end - def add_member(user, access_level, ...) - Members::Groups::CreatorService.add_member(self, user, access_level, ...) # rubocop:disable CodeReuse/ServiceClass + def add_member(user, access_level, **params) + # rubocop:disable CodeReuse/ServiceClass -- Service class contains all the business logic + Members::Groups::CreatorService.add_member(self, user, access_level, organization_id: organization_id, **params) + # rubocop:enable CodeReuse/ServiceClass end def add_guest(user, current_user = nil) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 85eb2b7dcfe833..6d4708cda72518 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -53,7 +53,8 @@ def add_members(users, access_level, current_user: nil, expires_at: nil) users, access_level, current_user: current_user, - expires_at: expires_at + expires_at: expires_at, + organization_id: project.organization_id ) end @@ -63,7 +64,9 @@ def add_member(user, access_level, current_user: nil, expires_at: nil) user, access_level, current_user: current_user, - expires_at: expires_at) + expires_at: expires_at, + organization_id: project.organization_id + ) end # Remove all users from project team diff --git a/app/models/user.rb b/app/models/user.rb index b4403061ddafd6..db5ca1510659af 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -763,6 +763,10 @@ def blocked? where(feed_token: Array.wrap(token_values)) end + scope :in_organization, ->(organization) do + where(organization: organization) + end + def self.supported_keyset_orderings { id: [:asc, :desc], diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 3a43f97c934ef4..fe973760370916 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -110,7 +110,8 @@ def creator_service def create_params { expires_at: params[:expires_at], - current_user: current_user + current_user: current_user, + organization_id: params[:organization_id] } end diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb index ea69536f75287b..fe723f9f168f5c 100644 --- a/app/services/members/creator_service.rb +++ b/app/services/members/creator_service.rb @@ -89,7 +89,8 @@ def parsed_args(args) { current_user: args[:current_user], expires_at: args[:expires_at], - ldap: args[:ldap] + ldap: args[:ldap], + organization_id: args[:organization_id] } end @@ -150,8 +151,9 @@ def parse_users_list(source, list) end end - def initialize(invitee:, builder: StandardMemberBuilder, **args) + def initialize(invitee:, organization_id: nil, builder: StandardMemberBuilder, **args) @invitee = invitee + @organization_id = organization_id @builder = builder @args = args @access_level = self.class.parsed_access_level(args[:access_level]) @@ -169,7 +171,7 @@ def execute private delegate :new_record?, to: :member - attr_reader :invitee, :access_level, :member, :args, :builder + attr_reader :invitee, :access_level, :member, :args, :builder, :organization_id def assign_member_attributes member.attributes = member_attributes @@ -182,6 +184,8 @@ def commit_member return add_authorization_error if role_too_high? + return add_not_valid_org_error unless same_org? + commit_changes end @@ -271,6 +275,19 @@ def add_authorization_error member.errors.add(:base, msg) end + def same_org? + return true unless organization_id + return true unless member.user + + member.user.organization_id == organization_id + end + + def add_not_valid_org_error + msg = _('already belongs to another organization') + + member.errors.add(:base, msg) + end + def find_or_build_member @member = builder.new(source, invitee, existing_members).execute end diff --git a/db/fixtures/development/03_project.rb b/db/fixtures/development/03_project.rb index 63b840030b4a5d..76721bc86c32b9 100644 --- a/db/fixtures/development/03_project.rb +++ b/db/fixtures/development/03_project.rb @@ -60,7 +60,7 @@ class Gitlab::Seeder::Projects def initialize(organization:) @organization = organization - end + end def seed! Sidekiq::Testing.inline! do @@ -128,7 +128,7 @@ def self.create_real_project!(organization:, url: nil, force_latest_storage: fal group.description = FFaker::Lorem.sentence group.save! - group.add_owner(User.first) + group.add_owner(User.in_organization(group.organization).first) group.create_namespace_settings end diff --git a/db/fixtures/development/06_teams.rb b/db/fixtures/development/06_teams.rb index 71fe0df70f4f67..015aacab38b2ee 100644 --- a/db/fixtures/development/06_teams.rb +++ b/db/fixtures/development/06_teams.rb @@ -3,7 +3,7 @@ Sidekiq::Testing.inline! do Gitlab::Seeder.quiet do Group.not_mass_generated.each do |group| - User.not_mass_generated.sample(4).each do |user| + User.in_organization(group.organization).not_mass_generated.sample(4).each do |user| if group.add_member(user, Gitlab::Access.values.sample).persisted? print '.' else @@ -13,7 +13,7 @@ end Project.not_mass_generated.each do |project| - User.not_mass_generated.sample(4).each do |user| + User.in_organization(project.organization).not_mass_generated.sample(4).each do |user| if project.add_role(user, Gitlab::Access.sym_options.keys.sample) print '.' else diff --git a/ee/app/controllers/concerns/group_invite_members.rb b/ee/app/controllers/concerns/group_invite_members.rb index 94bf17439102ab..c18354f7e28342 100644 --- a/ee/app/controllers/concerns/group_invite_members.rb +++ b/ee/app/controllers/concerns/group_invite_members.rb @@ -3,12 +3,13 @@ module GroupInviteMembers private - def invite_members(group, invite_source:) + def invite_members(group, invite_source:, organization_id:) invite_params = { source: group, user_id: emails_param[:emails]&.reject(&:blank?)&.join(','), access_level: Gitlab::Access::DEVELOPER, - invite_source: invite_source + invite_source: invite_source, + organization_id: organization_id } result = Members::CreateService.new(current_user, invite_params).execute diff --git a/ee/app/controllers/ee/groups_controller.rb b/ee/app/controllers/ee/groups_controller.rb index 95a4c386adb09f..0af86a2e01b247 100644 --- a/ee/app/controllers/ee/groups_controller.rb +++ b/ee/app/controllers/ee/groups_controller.rb @@ -87,7 +87,7 @@ def successful_creation_hooks super update_user_setup_for_company - invite_members(group, invite_source: 'group-creation-page') + invite_members(group, invite_source: 'group-creation-page', organization_id: Current.organization.id) end override :redirect_if_epic_params diff --git a/ee/db/fixtures/development/22_epics.rb b/ee/db/fixtures/development/22_epics.rb index 1313501533e387..d6d388828c5a39 100644 --- a/ee/db/fixtures/development/22_epics.rb +++ b/ee/db/fixtures/development/22_epics.rb @@ -14,11 +14,15 @@ labels: group.labels.sample(rand(6)).pluck(:title).join(',') } params[:closed_at] = rand(days_ago).days.ago if params[:state] == 'closed' + current_user = group.users.sample epic = WorkItems::LegacyEpics::CreateService - .new(group: group, current_user: group.users.sample, params: params).execute_without_rate_limiting + .new(group:, current_user:, params:).execute_without_rate_limiting print '.' if epic.persisted? + rescue StandardError => e + Gitlab::Seeder.log_message("Failed creating epics for group: #{group.name}") + raise e end end end diff --git a/ee/spec/requests/api/members_spec.rb b/ee/spec/requests/api/members_spec.rb index 7576dd7d8114fe..f121cfc22a86f0 100644 --- a/ee/spec/requests/api/members_spec.rb +++ b/ee/spec/requests/api/members_spec.rb @@ -2330,7 +2330,7 @@ RSpec.shared_examples 'creates multiple memberships' do before do - allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(111) + allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(112) end it do diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 630deb64de6a4e..157dfd3e49f292 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -111,6 +111,9 @@ def current_user # rubocop:enable Gitlab/ModuleWithInstanceVariables def set_current_organization(user: current_user) + # We're sending through the correct header, but grape is converting it to something else. + request.headers[Gitlab::Current::Organization::HTTP_HEADER] ||= request.headers['X-Gitlab-Organization-Id'] + ::Current.organization = Gitlab::Current::Organization.new( params: {}, user: user, diff --git a/lib/api/invitations.rb b/lib/api/invitations.rb index 526bdfcfcd32c0..4651da4b9abcd7 100644 --- a/lib/api/invitations.rb +++ b/lib/api/invitations.rb @@ -6,7 +6,10 @@ class Invitations < ::API::Base feature_category :user_profile - before { authenticate! } + before do + authenticate! + set_current_organization + end helpers ::API::Helpers::MembersHelpers @@ -48,7 +51,7 @@ class Invitations < ::API::Base authorize_invite_source_member!(source_type, source) - create_service_params = declared_params.merge(source: source) + create_service_params = declared_params.merge(source: source, organization_id: Current.organization.id) ::Members::InviteService.new(current_user, create_service_params).execute end diff --git a/lib/api/members.rb b/lib/api/members.rb index be1c58ce585f64..eacc375c85a71e 100644 --- a/lib/api/members.rb +++ b/lib/api/members.rb @@ -4,7 +4,10 @@ module API class Members < ::API::Base include PaginationParams - before { authenticate! } + before do + authenticate! + set_current_organization + end urgency :low @@ -129,7 +132,7 @@ class Members < ::API::Base post ":id/members", feature_category: feature_category do source = find_source(source_type, params[:id]) - create_service_params = params.merge(source: source) + create_service_params = params.merge(source: source, organization_id: Current.organization.id) if add_multiple_members?(params[:user_id].to_s, params[:username]) ::Members::CreateService.new(current_user, create_service_params).execute diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 019b466aa29b0e..4804c44ac38036 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -77738,6 +77738,9 @@ msgstr "" msgid "already being used for another iteration within this cadence." msgstr "" +msgid "already belongs to another organization" +msgstr "" + msgid "already exists for the given recipe revision, package reference, and package revision" msgstr "" diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb index 58cab80e2f11df..f80401b2a8344d 100644 --- a/spec/finders/users_finder_spec.rb +++ b/spec/finders/users_finder_spec.rb @@ -206,6 +206,16 @@ expect(users).not_to include(project_user) end end + + context 'when filtering by organization' do + let_it_be(:other_organization) { create(:organization) } + let_it_be(:other_organization_user) { create(:user, organization: other_organization) } + + it 'returns correct user' do + users = described_class.new(user, organization_id: other_organization.id).execute + expect(users).to contain_exactly(other_organization_user) + end + end end shared_examples 'executes users finder as admin' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 4c4bfdbccc5bd5..bce65e597e56c8 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2026,6 +2026,20 @@ expect(described_class.with_feed_token('nonexistent')).to be_empty end end + + describe '.in_organization' do + let_it_be(:user) { create(:user) } + let_it_be(:organization) { user.organization } + let_it_be(:other_organization) { create(:organization) } + + it 'includes the user' do + expect(described_class.in_organization(organization)).to eq([user]) + end + + it 'excludes the user' do + expect(described_class.in_organization(other_organization)).to eq([]) + end + end end context 'strip attributes' do diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index c71732ae85b37f..f138a8e79ffc25 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -422,6 +422,16 @@ def set_param(key, value) expect(Current.organization).to eq(header_organization) end + + it 'sets the org when header is in titlecase' do + # Subtle, but note the L in gitlab and D in ID are downcased compared + # to X-GitLab-Organization-ID. It seems like Grape is changing the headers. + request.headers['X-Gitlab-Organization-Id'] = header_organization.id.to_s + + set_current_organization(user: user) + + expect(Current.organization).to eq(header_organization) + end end end diff --git a/spec/requests/groups/group_members_controller_spec.rb b/spec/requests/groups/group_members_controller_spec.rb index 5b7d81680ae65e..7626404597de40 100644 --- a/spec/requests/groups/group_members_controller_spec.rb +++ b/spec/requests/groups/group_members_controller_spec.rb @@ -36,6 +36,8 @@ let_it_be(:internal_user) { Users::Internal.alert_bot } let_it_be(:project_bot_user) { create(:user, :project_bot) } let_it_be(:service_account_user) { create(:user, :service_account) } + let_it_be(:other_organization) { create(:organization) } + let_it_be(:other_organization_user) { create(:user, organization: other_organization) } let(:searchable_users) do [ @@ -63,6 +65,7 @@ expect(response).to have_gitlab_http_status(:ok) expect(json_response.pluck('id')).to match_array(searchable_users.map(&:id)) + expect(json_response.pluck('id')).not_to include(other_organization_user.id) end context 'for search param' do diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb index 3b063e879b14d1..7bc1b7e7db263c 100644 --- a/spec/services/members/create_service_spec.rb +++ b/spec/services/members/create_service_spec.rb @@ -8,12 +8,12 @@ let_it_be_with_reload(:user) { create(:user) } let_it_be_with_reload(:member) { create(:user) } let_it_be(:user_invited_by_id) { create(:user) } - let_it_be(:user_id) { member.id.to_s } let_it_be(:access_level) { Gitlab::Access::GUEST } let(:additional_params) { { invite_source: '_invite_source_' } } let(:params) { { user_id: user_id, access_level: access_level }.merge(additional_params) } let(:current_user) { user } + let(:user_id) { member.id.to_s } subject(:execute_service) { described_class.new(current_user, params.merge({ source: source })).execute } @@ -343,6 +343,32 @@ end end + context 'when passing organization_id parameter' do + let_it_be(:organization) { source.organization } + let(:user_id) { member.id.to_s } + let(:additional_params) do + { invite_source: '_invite_source_', organization_id: organization.id, user_id: user_id } + end + + context 'when inviting from the same organization' do + it 'adds the member' do + expect(execute_service[:status]).to eq(:success) + expect(source.users).to include member + end + end + + context 'when inviting from a different organization' do + let_it_be(:other_organization) { create(:organization) } + let_it_be(:other_member) { create(:user, organization: other_organization) } + let(:user_id) { other_member.id.to_s } + + it 'does not add the member' do + expect(execute_service[:status]).to eq(:error) + expect(source.users).not_to include other_member + end + end + end + context 'with raised errors' do using RSpec::Parameterized::TableSyntax diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb index ee44f889a76b5b..cb50a233e3f254 100644 --- a/spec/services/members/invite_service_spec.rb +++ b/spec/services/members/invite_service_spec.rb @@ -5,13 +5,21 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_shared_state, :sidekiq_inline, feature_category: :groups_and_projects do let_it_be(:project, reload: true) { create(:project) } + let_it_be(:organization) { project.organization } let_it_be(:user) { project.first_owner } let_it_be(:project_user, reload: true) { create(:user) } let_it_be(:user_invited_by_id) { create(:user) } let_it_be(:namespace) { project.namespace } let(:params) { {} } - let(:base_params) { { access_level: Gitlab::Access::GUEST, source: project, invite_source: '_invite_source_' } } + let(:base_params) do + { + access_level: Gitlab::Access::GUEST, + source: project, + invite_source: '_invite_source_', + organization_id: organization.id + } + end subject(:result) { described_class.new(user, base_params.merge(params)).execute } @@ -608,6 +616,46 @@ expect(result[:message]['invalid-email']).to eq('Invite email is invalid') end end + + context 'when adding a user' do + context 'in a different org' do + let_it_be(:other_organization) { create(:organization) } + + context 'with user_id' do + let(:params) { { organization_id: other_organization.id, user_id: project_user.id } } + + it 'does not add project members' do + expect_not_to_create_members + end + end + + context 'with email linked to another org' do + let(:params) { { organization_id: other_organization.id, email: project_user.email } } + + it 'does not add project members' do + expect_not_to_create_members + end + end + end + + context 'in the same org' do + context 'with user_id' do + let(:params) { { organization_id: organization.id, user_id: project_user.id } } + + it 'does not add project members' do + expect_to_create_members(count: 1) + end + end + + context 'with email linked to another org' do + let(:params) { { organization_id: organization.id, email: project_user.email } } + + it 'does not add project members' do + expect_to_create_members(count: 1) + end + end + end + end end def expect_to_create_members(count:) diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb index 250791b1e60ad2..23272ab8d0f9e1 100644 --- a/spec/support/shared_examples/models/member_shared_examples.rb +++ b/spec/support/shared_examples/models/member_shared_examples.rb @@ -382,6 +382,24 @@ end end end + + context 'when organization_id is passed in' do + let_it_be(:organization) { user.organization } + let_it_be(:other_organization) { create(:organization) } + let_it_be(:other_user) { create(:user, organization: other_organization) } + + it 'does not add member from different organization' do + expect do + described_class.add_member(source, other_user, :guest, organization_id: organization.id) + end.not_to change { Member.count } + end + + it 'adds member from the same organization' do + expect do + described_class.add_member(source, user, :guest, organization_id: organization.id) + end.to change { Member.count }.by(1) + end + end end RSpec.shared_examples_for "bulk member creation" do @@ -554,6 +572,27 @@ expect(members).to all(be_persisted) end.to change { Member.count }.by(2) end + + context 'when organization_id is passed in' do + let_it_be(:organization) { user.organization } + let_it_be(:other_organization) { create(:organization) } + let_it_be(:other_user) { create(:user, organization: other_organization) } + + it 'does not add member from different organization' do + expect do + members = described_class.add_members(source, [other_user.id], :guest, organization_id: organization.id) + member = members.first + + expect(member.errors.full_messages).to include(/already belongs to another organization/) + end.not_to change { Member.count } + end + + it 'adds member from the same organization' do + expect do + described_class.add_members(source, [user.id], :guest, organization_id: organization.id) + end.to change { Member.count }.by(1) + end + end end end -- GitLab