diff --git a/ee/app/models/saml_group_link.rb b/ee/app/models/saml_group_link.rb index c78a60e9b15e934e2865f88e54e5210a0aec43f4..77cee39a36b572a79ce0ad05df357f2fa826b595 100644 --- a/ee/app/models/saml_group_link.rb +++ b/ee/app/models/saml_group_link.rb @@ -18,9 +18,14 @@ class SamlGroupLink < ApplicationRecord scope :by_id_and_group_id, ->(id, group_id) { where(id: id, group_id: group_id) } scope :by_saml_group_name, ->(name) { where(saml_group_name: name) } scope :by_group_id, ->(group_id) { where(group_id: group_id) } + scope :by_scim_group_uid, ->(uid) { where(scim_group_uid: uid) } scope :by_assign_duo_seats, ->(value) { where(assign_duo_seats: value) } scope :preload_group, -> { preload(group: :route) } + def self.first_by_scim_group_uid(uid) + by_scim_group_uid(uid).order(:id).take + end + def access_level_allowed return unless group return if access_level.in?(group.access_level_roles.values) diff --git a/ee/lib/api/scim/instance_scim.rb b/ee/lib/api/scim/instance_scim.rb index 0915b65ff19829e776121d9bb553b1e3408d6848..33eca580fe76f83c83310c3656c2fdc9cc824d58 100644 --- a/ee/lib/api/scim/instance_scim.rb +++ b/ee/lib/api/scim/instance_scim.rb @@ -166,6 +166,15 @@ def reprovision(identity) def check_groups_feature_enabled! not_found! unless Feature.enabled?(:self_managed_scim_group_sync, :instance) end + + def find_group_link(scim_group_uid) + # We only need one group link since they'll all have the same name and SCIM ID. + # Multiple links can exist if the same SAML group is linked to different GitLab groups. + group_link = SamlGroupLink.first_by_scim_group_uid(scim_group_uid) + + scim_not_found!(message: "Group #{scim_group_uid} not found") unless group_link + group_link + end end before do @@ -197,6 +206,20 @@ def check_groups_feature_enabled! scim_error!(message: result.message) end end + + desc 'Get a SCIM group' do + detail 'Retrieves a SCIM group by its ID' + success ::EE::API::Entities::Scim::Group + end + params do + requires :id, type: String, desc: 'The SCIM group ID' + end + get ':id' do + check_access! + + group_link = find_group_link(params[:id]) + present group_link, with: ::EE::API::Entities::Scim::Group + end end end end diff --git a/ee/spec/models/saml_group_link_spec.rb b/ee/spec/models/saml_group_link_spec.rb index 49afc1c67dc65d9376090ef4dc2b587def50e721..a14c80c421e5814f65f2e5d5ba5c004cdc10a855 100644 --- a/ee/spec/models/saml_group_link_spec.rb +++ b/ee/spec/models/saml_group_link_spec.rb @@ -110,6 +110,34 @@ def saml_group_link(group:) end end + describe '.by_scim_group_uid' do + let_it_be(:uid) { SecureRandom.uuid } + let_it_be(:group_link_with_uid) { create(:saml_group_link, group: group, scim_group_uid: uid) } + + it 'finds the group link' do + results = described_class.by_scim_group_uid(uid) + + expect(results).to match_array([group_link_with_uid]) + end + + it 'returns empty when no matches exist' do + results = described_class.by_scim_group_uid(SecureRandom.uuid) + + expect(results).to be_empty + end + + context 'with multiple groups and group links' do + let_it_be(:group2) { create(:group) } + let_it_be(:group_link2) { create(:saml_group_link, group: group2, scim_group_uid: uid) } + + it 'finds all matching group links' do + results = described_class.by_scim_group_uid(uid) + + expect(results).to match_array([group_link_with_uid, group_link2]) + end + end + end + describe '.by_assign_duo_seats' do let_it_be(:group_link_w_assign_duo_seats) { create(:saml_group_link, assign_duo_seats: true) } @@ -133,4 +161,27 @@ def saml_group_link(group:) it { is_expected.to be_valid } end end + + describe '.first_by_scim_group_uid' do + let_it_be(:group) { create(:group) } + let_it_be(:uid) { SecureRandom.uuid } + let_it_be(:group_link_with_uid) { create(:saml_group_link, group: group, scim_group_uid: uid) } + + it 'returns the first matching group link' do + expect(described_class.first_by_scim_group_uid(uid)).to eq(group_link_with_uid) + end + + it 'returns nil when no matches exist' do + expect(described_class.first_by_scim_group_uid(SecureRandom.uuid)).to be_nil + end + + context 'when multiple matches exist' do + let_it_be(:group2) { create(:group) } + let_it_be(:another_group_link) { create(:saml_group_link, group: group2, scim_group_uid: uid) } + + it 'returns only one group link' do + expect(described_class.first_by_scim_group_uid(uid)).to eq(group_link_with_uid) + end + end + end end diff --git a/ee/spec/requests/api/scim/instance_scim_spec.rb b/ee/spec/requests/api/scim/instance_scim_spec.rb index 08694d44b4a48290319e8b11d72991974f0af6ea..866f46a7d2ba660a3457faa8f0e4dc40a6affe08 100644 --- a/ee/spec/requests/api/scim/instance_scim_spec.rb +++ b/ee/spec/requests/api/scim/instance_scim_spec.rb @@ -642,5 +642,112 @@ end end end + + describe 'GET api/scim/v2/application/Groups/:id' do + let(:scim_group_uid) { SecureRandom.uuid } + let!(:saml_group_link) do + create(:saml_group_link, saml_group_name: 'engineering', scim_group_uid: scim_group_uid) + end + + subject(:api_request) do + get api("scim/v2/application/Groups/#{scim_group_uid}", user, version: '', access_token: scim_token) + end + + it_behaves_like 'Groups feature flag check' + it_behaves_like 'Not available to SaaS customers' + it_behaves_like 'Instance level SCIM license required' + it_behaves_like 'SCIM token authenticated' + it_behaves_like 'SAML SSO must be enabled' + it_behaves_like 'sets current organization' + + context 'with valid SCIM group ID' do + it 'responds with 200 and the group attributes' do + api_request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['id']).to eq(scim_group_uid) + expect(json_response['displayName']).to eq(saml_group_link.saml_group_name) + expect(json_response['schemas']).to eq(['urn:ietf:params:scim:schemas:core:2.0:Group']) + expect(json_response['meta']['resourceType']).to eq('Group') + end + + it 'uses the by_scim_group_uid scope' do + expect(SamlGroupLink).to receive(:by_scim_group_uid).with(scim_group_uid).and_call_original + + api_request + end + + it 'returns a valid SCIM Group schema' do + api_request + + expect(json_response).to include( + 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:Group'], + 'id' => scim_group_uid, + 'displayName' => saml_group_link.saml_group_name, + 'members' => [], + 'meta' => { 'resourceType' => 'Group' } + ) + end + end + + context 'with non-existent SCIM group ID' do + let(:non_existent_scim_group) { 123456789 } + + subject(:api_request) do + get api("scim/v2/application/Groups/#{non_existent_scim_group}", user, version: '', access_token: scim_token) + end + + it 'returns a 404 not found' do + api_request + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['detail']).to include("Group #{non_existent_scim_group} not found") + end + end + + context 'with special characters in SCIM group ID' do + shared_examples 'returns not found' do |uid| + subject(:api_request) do + get api("scim/v2/application/Groups/#{ERB::Util.url_encode(uid)}", user, version: '', + access_token: scim_token) + end + + it 'returns 404 not found' do + api_request + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['detail']).to include("Group #{uid} not found") + end + end + + it_behaves_like 'returns not found', 'group/with/slashes' + it_behaves_like 'returns not found', 'group with spaces' + it_behaves_like 'returns not found', 'group@with@special@chars' + end + + context 'when multiple groups have the same SCIM group ID' do + let!(:another_group_link) { create(:saml_group_link, scim_group_uid: scim_group_uid) } + + it 'returns the first matching group' do + api_request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['id']).to eq(scim_group_uid) + end + end + + context 'with invalid SCIM group ID format' do + subject(:api_request) do + get api("scim/v2/application/Groups/invalid%20id", user, version: '', access_token: scim_token) + end + + it 'returns a 404 not found' do + api_request + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['detail']).to include('Group invalid id not found') + end + end + end end end