From d942843296c886aa9800de4bebda635653b485d7 Mon Sep 17 00:00:00 2001 From: Bogdan Denkovych Date: Fri, 6 Jun 2025 14:17:55 +0300 Subject: [PATCH] Add "GET /groups/:id/saml_users" API endpoint Related to https://gitlab.com/gitlab-org/gitlab/-/merge_requests/190512 and https://gitlab.com/gitlab-org/gitlab/-/merge_requests/189638. Changelog: added EE: true --- doc/api/groups.md | 103 +++++++ .../finders/authn/group_saml_users_finder.rb | 33 ++ ee/lib/ee/api/groups.rb | 27 ++ .../authn/group_saml_users_finder_spec.rb | 152 ++++++++++ ee/spec/requests/api/groups_spec.rb | 282 ++++++++++++++++++ 5 files changed, 597 insertions(+) create mode 100644 ee/app/finders/authn/group_saml_users_finder.rb create mode 100644 ee/spec/finders/authn/group_saml_users_finder_spec.rb diff --git a/doc/api/groups.md b/doc/api/groups.md index 6fa6d26c57eccc..5be04b0e751ffc 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -732,6 +732,109 @@ Example response: ] ``` +### List all SAML users + +{{< details >}} + +- Tier: Premium, Ultimate +- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated + +{{< /details >}} + +{{< history >}} + +- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/193748) in GitLab 18.1. + +{{< /history >}} + +Lists all SAML users for a given top-level group. + +Use the `page` and `per_page` [pagination parameters](rest/_index.md#offset-based-pagination) to filter the results. + +```plaintext +GET /groups/:id/saml_users +``` + +Supported attributes: + +| Attribute | Type | Required | Description | +|:-----------------|:---------------|:---------|:------------| +| `id` | integer/string | yes | ID or [URL-encoded path](rest/_index.md#namespaced-paths) of a top-level group. | +| `username` | string | no | Return a user with a given username. | +| `search` | string | no | Return users with a matching name, email, or username. Use partial values to increase results. | +| `active` | boolean | no | Return only active users. | +| `blocked` | boolean | no | Return only blocked users. | +| `created_after` | datetime | no | Return users created after the specified time. Format: ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`). | +| `created_before` | datetime | no | Return users created before the specified time. Format: ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`). | + +Example request: + +```shell +curl --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/groups/:id/saml_users" +``` + +Example response: + +```json +[ + { + "id": 66, + "username": "user22", + "name": "Sidney Jones22", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/xxx?s=80&d=identicon", + "web_url": "http://my.gitlab.com/user22", + "created_at": "2021-09-10T12:48:22.381Z", + "bio": "", + "location": null, + "public_email": "", + "skype": "", + "linkedin": "", + "twitter": "", + "website_url": "", + "organization": null, + "job_title": "", + "pronouns": null, + "bot": false, + "work_information": null, + "followers": 0, + "following": 0, + "local_time": null, + "last_sign_in_at": null, + "confirmed_at": "2021-09-10T12:48:22.330Z", + "last_activity_on": null, + "email": "user22@example.org", + "theme_id": 1, + "color_scheme_id": 1, + "projects_limit": 100000, + "current_sign_in_at": null, + "identities": [ + { + "provider": "group_saml", + "extern_uid": "2435223452345", + "saml_provider_id": 1 + } + ], + "can_create_group": true, + "can_create_project": true, + "two_factor_enabled": false, + "external": false, + "private_profile": false, + "commit_email": "user22@example.org", + "shared_runners_minutes_limit": null, + "extra_shared_runners_minutes_limit": null, + "scim_identities": [ + { + "extern_uid": "2435223452345", + "group_id": 1, + "active": true + } + ] + }, + ... +] +``` + ### List provisioned users {{< details >}} diff --git a/ee/app/finders/authn/group_saml_users_finder.rb b/ee/app/finders/authn/group_saml_users_finder.rb new file mode 100644 index 00000000000000..7c9176e36fa2be --- /dev/null +++ b/ee/app/finders/authn/group_saml_users_finder.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Authn + class GroupSamlUsersFinder < UsersFinder + extend ::Gitlab::Utils::Override + + private + + override :base_scope + def base_scope + group = params[:group] + + raise(ArgumentError, 'Group is required for GroupSamlUsersFinder') unless group + raise(ArgumentError, 'Group must be a top-level group') unless group.root? + raise Gitlab::Access::AccessDeniedError unless user_owner_of_group?(group) + + return User.none unless group.saml_provider + + User.with_saml_provider(group.saml_provider).order_id_desc + end + + override :by_search + def by_search(users) + return users unless params[:search].present? + + users.search(params[:search], with_private_emails: true) + end + + def user_owner_of_group?(group) + Ability.allowed?(current_user, :owner_access, group) + end + end +end diff --git a/ee/lib/ee/api/groups.rb b/ee/lib/ee/api/groups.rb index a37a2da94b424c..161446da2391fb 100644 --- a/ee/lib/ee/api/groups.rb +++ b/ee/lib/ee/api/groups.rb @@ -215,6 +215,33 @@ def check_ssh_certificate_available_to_group(group) end end + desc 'Get a list of SAML users of the group' do + success ::API::Entities::UserPublic + is_array true + end + params do + optional :username, type: String, desc: 'Return single user with a specific username.' + optional :search, type: String, desc: 'Search users by name, email, username.' + optional :active, type: Grape::API::Boolean, default: false, desc: 'Return only active users.' + optional :blocked, type: Grape::API::Boolean, default: false, desc: 'Return only blocked users.' + optional :created_after, type: DateTime, desc: 'Return users created after the specified time.' + optional :created_before, type: DateTime, desc: 'Return users created before the specified time.' + + use :pagination + end + get ':id/saml_users', feature_category: :system_access do + authenticate! + bad_request!('Must be a top-level group') unless user_group.root? + + finder = ::Authn::GroupSamlUsersFinder.new( + current_user, + declared_params.merge(group: user_group)) + + users = finder.execute.preload(:identities, :group_scim_identities, :instance_scim_identities) # rubocop: disable CodeReuse/ActiveRecord -- preload + + present paginate(users), with: ::API::Entities::UserPublic + end + desc 'Get a list of users provisioned by the group' do success ::API::Entities::UserPublic end diff --git a/ee/spec/finders/authn/group_saml_users_finder_spec.rb b/ee/spec/finders/authn/group_saml_users_finder_spec.rb new file mode 100644 index 00000000000000..9c416f289146d7 --- /dev/null +++ b/ee/spec/finders/authn/group_saml_users_finder_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Authn::GroupSamlUsersFinder, feature_category: :system_access do + describe '#execute' do + let_it_be_with_reload(:group) { create(:group) } + let_it_be_with_reload(:saml_provider) { create(:saml_provider, group: group) } + + let_it_be(:subgroup) { create(:group, parent: group) } + + let_it_be(:maintainer_of_the_group) { create(:user, maintainer_of: group) } + let_it_be(:owner_of_the_group) { create(:user, owner_of: group) } + + let_it_be(:non_saml_user) { create(:user) } + let_it_be(:saml_user_of_another_group) { create(:group_saml_identity).user } + let_it_be(:non_saml_user_with_identity) { create(:omniauth_user, provider: 'google') } + + let_it_be(:saml_user_of_the_group) { create(:group_saml_identity, saml_provider: saml_provider).user } + + let_it_be(:blocked_saml_user_of_the_group) do + create(:group_saml_identity, saml_provider: saml_provider, user: create(:user, :blocked)).user + end + + let(:current_user) { owner_of_the_group } + + let(:params) { { group: group } } + + subject(:finder) { described_class.new(current_user, params).execute } + + context 'when group parameter is not passed' do + let(:params) { {} } + + it 'raises error that group is required' do + expect { finder }.to raise_error(ArgumentError, 'Group is required for GroupSamlUsersFinder') + end + end + + context 'when group parameter is not top-level group' do + let(:params) { { group: subgroup } } + + it 'raises error that group must be a top-level group' do + expect { finder }.to raise_error(ArgumentError, 'Group must be a top-level group') + end + end + + context 'when current_user is not owner of the group' do + let(:current_user) { maintainer_of_the_group } + + it 'raises Gitlab::Access::AccessDeniedError' do + expect { finder }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + it 'returns SAML users of the group in descending order by id' do + users = finder + + expect(users).to eq( + [ + saml_user_of_the_group, + blocked_saml_user_of_the_group + ].sort_by(&:id).reverse + ) + end + + context 'when group does not have saml_provider' do + before_all do + saml_provider.destroy! + end + + it 'does not return any users' do + users = finder + + expect(users).to eq([]) + end + end + + context 'for search parameter' do + context 'for search by name' do + let(:params) { { group: group, search: saml_user_of_the_group.name } } + + it 'returns SAML users of the group according to the search parameter' do + users = finder + + expect(users).to eq( + [ + saml_user_of_the_group + ] + ) + end + end + + context 'for search by username' do + let(:params) { { group: group, search: blocked_saml_user_of_the_group.username } } + + it 'returns SAML users of the group according to the search parameter' do + users = finder + + expect(users).to eq( + [ + blocked_saml_user_of_the_group + ] + ) + end + end + + context 'for search by public email' do + let_it_be(:saml_user_of_the_group_with_public_email) do + create(:group_saml_identity, saml_provider: saml_provider, user: create(:user, :public_email)).user + end + + let(:params) do + { group: group, search: saml_user_of_the_group_with_public_email.public_email } + end + + it 'returns SAML users of the group according to the search parameter', :aggregate_failures do + expect(saml_user_of_the_group_with_public_email.public_email).to be_present + + users = finder + + expect(users).to eq( + [ + saml_user_of_the_group_with_public_email + ] + ) + end + end + + context 'for search by private email' do + let_it_be(:saml_user_of_the_group_without_public_email) do + create(:group_saml_identity, saml_provider: saml_provider, user: create(:user)).user + end + + let(:params) do + { group: group, search: saml_user_of_the_group_without_public_email.email } + end + + it 'returns SAML users of the group according to the search parameter', :aggregate_failures do + expect(saml_user_of_the_group_without_public_email.public_email).not_to be_present + + users = finder + + expect(users).to eq( + [ + saml_user_of_the_group_without_public_email + ] + ) + end + end + end + end +end diff --git a/ee/spec/requests/api/groups_spec.rb b/ee/spec/requests/api/groups_spec.rb index 222de9a9581d71..1190c20c289b48 100644 --- a/ee/spec/requests/api/groups_spec.rb +++ b/ee/spec/requests/api/groups_spec.rb @@ -1493,6 +1493,288 @@ end end + describe 'GET /groups/:id/saml_users' do + subject(:get_group_saml_users) do + get api("/groups/#{group_id}/saml_users", current_user), params: params + end + + let_it_be_with_reload(:group) { create(:group) } + let_it_be_with_reload(:saml_provider) { create(:saml_provider, group: group) } + + let_it_be(:subgroup) { create(:group, parent: group) } + + let_it_be(:maintainer_of_the_group) { create(:user, maintainer_of: group) } + let_it_be(:owner_of_the_group) { create(:user, owner_of: group) } + + let_it_be(:non_saml_user) { create(:user) } + let_it_be(:saml_user_of_another_group) { create(:group_saml_identity).user } + let_it_be(:non_saml_user_with_identity) { create(:omniauth_user, provider: 'google') } + + let_it_be(:saml_user_of_the_group) { create(:group_saml_identity, saml_provider: saml_provider).user } + let_it_be(:saml_user_of_the_group2) { create(:group_saml_identity, saml_provider: saml_provider).user } + + let_it_be(:blocked_saml_user_of_the_group) do + create(:group_saml_identity, saml_provider: saml_provider, user: create(:user, :blocked)).user + end + + let(:current_user) { owner_of_the_group } + let(:group_id) { group.id } + let(:params) { {} } + + context 'when current_user is nil' do + let(:current_user) { nil } + + it 'returns 401 Unauthorized' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:unauthorized) + expect(json_response['message']).to eq('401 Unauthorized') + end + end + + context 'when group is not found' do + let(:group_id) { -42 } + + it 'returns 404 Group Not Found' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Group Not Found') + end + end + + context 'when group is not top-level group' do + let(:group_id) { subgroup.id } + + it 'returns 400 Bad Request with message' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq('400 Bad request - Must be a top-level group') + end + end + + context 'when current_user is not owner of the group' do + let(:current_user) { maintainer_of_the_group } + + it 'returns 403 Forbidden' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('403 Forbidden') + end + end + + it 'returns SAML users of the group in descending order by id' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + saml_user_of_the_group, + saml_user_of_the_group2, + blocked_saml_user_of_the_group + ].sort_by(&:id).reverse.pluck(:id) + ) + end + + context 'when group does not have saml_provider' do + before_all do + saml_provider.destroy! + end + + it 'does not return any users' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq([]) + end + end + + context 'for pagination parameters' do + let(:params) { { page: 1, per_page: 2 } } + + it 'returns SAML users according to page and per_page parameters' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + saml_user_of_the_group, + saml_user_of_the_group2, + blocked_saml_user_of_the_group + ].sort_by(&:id).reverse.slice(0, 2).pluck(:id) + ) + end + end + + context 'for username parameter' do + let(:params) { { username: saml_user_of_the_group.username } } + + it 'returns single SAML user with a specific username' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(saml_user_of_the_group.id) + end + end + + context 'for search parameter' do + context 'for search by name' do + let(:params) { { search: saml_user_of_the_group.name } } + + it 'returns SAML users of the group according to the search parameter' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(saml_user_of_the_group.id) + end + end + + context 'for search by username' do + let(:params) { { search: blocked_saml_user_of_the_group.username } } + + it 'returns SAML users of the group according to the search parameter' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(blocked_saml_user_of_the_group.id) + end + end + + context 'for search by public email' do + let_it_be(:saml_user_of_the_group_with_public_email) do + create(:group_saml_identity, saml_provider: saml_provider, user: create(:user, :public_email)).user + end + + let(:params) do + { search: saml_user_of_the_group_with_public_email.public_email } + end + + it 'returns SAML users of the group according to the search parameter' do + expect(saml_user_of_the_group_with_public_email.public_email).to be_present + + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(saml_user_of_the_group_with_public_email.id) + end + end + + context 'for search by private email' do + let_it_be(:saml_user_of_the_group_without_public_email) do + create(:group_saml_identity, saml_provider: saml_provider, user: create(:user)).user + end + + let(:params) do + { search: saml_user_of_the_group_without_public_email.email } + end + + it 'returns SAML users of the group according to the search parameter' do + expect(saml_user_of_the_group_without_public_email.public_email).not_to be_present + + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(saml_user_of_the_group_without_public_email.id) + end + end + end + + context 'for active parameter' do + let(:params) { { active: true } } + + it 'returns only active SAML users' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + saml_user_of_the_group, + saml_user_of_the_group2 + ].sort_by(&:id).reverse.pluck(:id) + ) + end + end + + context 'for blocked parameter' do + let(:params) { { blocked: true } } + + it 'returns only blocked SAML users' do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + blocked_saml_user_of_the_group + ].sort_by(&:id).reverse.pluck(:id) + ) + end + end + + context 'for created_after parameter' do + let(:params) { { created_after: 10.days.ago } } + + let_it_be(:saml_user_of_the_group_created_12_days_ago) do + create(:group_saml_identity, saml_provider: saml_provider).user.tap do |user| + user.update_column(:created_at, 12.days.ago) + end + end + + let_it_be(:saml_user_of_the_group_created_8_days_ago) do + create(:group_saml_identity, saml_provider: saml_provider).user.tap do |user| + user.update_column(:created_at, 8.days.ago) + end + end + + it 'returns only SAML users created after the specified time', :freeze_time do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + saml_user_of_the_group, + saml_user_of_the_group2, + blocked_saml_user_of_the_group, + saml_user_of_the_group_created_8_days_ago + ].sort_by(&:id).reverse.pluck(:id) + ) + end + end + + context 'for created_before parameter' do + let(:params) { { created_before: 10.days.ago } } + + let_it_be(:saml_user_of_the_group_created_12_days_ago) do + create(:group_saml_identity, saml_provider: saml_provider).user.tap do |user| + user.update_column(:created_at, 12.days.ago) + end + end + + let_it_be(:saml_user_of_the_group_created_8_days_ago) do + create(:group_saml_identity, saml_provider: saml_provider).user.tap do |user| + user.update_column(:created_at, 8.days.ago) + end + end + + it 'returns only SAML users created before the specified time', :freeze_time do + get_group_saml_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + saml_user_of_the_group_created_12_days_ago + ].sort_by(&:id).reverse.pluck(:id) + ) + end + end + end + describe 'GET /groups/:id/provisioned_users' do let_it_be(:group) { create(:group) } let_it_be(:regular_user) { create(:user) } -- GitLab