diff --git a/doc/api/group_enterprise_users.md b/doc/api/group_enterprise_users.md new file mode 100644 index 0000000000000000000000000000000000000000..91877eecae9a578afed5c3731d3edd407ce8250c --- /dev/null +++ b/doc/api/group_enterprise_users.md @@ -0,0 +1,112 @@ +--- +stage: Software Supply Chain Security +group: Authentication +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Group enterprise users API + +DETAILS: +**Tier:** Premium, Ultimate +**Offering:** GitLab.com + +Interact with [enterprise users](../user/enterprise_user/index.md) using the REST API. + +These API endpoints only work for top-level groups. Users do not have to be a member of the group. + +Prerequisites: + +- You must have the Owner role in the group. + +## List enterprise users + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/438366) in GitLab 17.7. + +Gets a list of enterprise users for a given top-level group. + +Takes [pagination parameters](rest/index.md#offset-based-pagination) `page` and `per_page` to restrict the list of enterprise users. + +```plaintext +GET /groups/:id/enterprise_users +``` + +Parameters: + +| 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 single user with a specific username. | +| `search` | string | no | Search users by name, email, username. | +| `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`). | +| `two_factor` | string | no | Filter users by two-factor authentication (2FA). Filter values are `enabled` or `disabled`. By default it returns all users. | + +Example request: + +```shell +curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/:id/enterprise_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 + } + ] + }, + ... +] +``` diff --git a/doc/user/enterprise_user/index.md b/doc/user/enterprise_user/index.md index 8d4a6a2b09ea0a7025c150cd3d4b2e1faa62bf05..8b435f964d7c5eb9ae0af96649b877c04565fe8c 100644 --- a/doc/user/enterprise_user/index.md +++ b/doc/user/enterprise_user/index.md @@ -248,6 +248,10 @@ Changing an enterprise user's primary email to an email from a non-verified doma A top-level group Owner can [disable password authentication for enterprise users](../group/saml_sso/index.md#disable-password-authentication-for-enterprise-users). +## Related topics + +- [Group enterprise users API](../../api/group_enterprise_users.md) + ## Troubleshooting ### Cannot disable two-factor authentication for an enterprise user diff --git a/ee/app/finders/authn/enterprise_users_finder.rb b/ee/app/finders/authn/enterprise_users_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..cb7672afbcf3fb17bf35c2551b1c24476d684de2 --- /dev/null +++ b/ee/app/finders/authn/enterprise_users_finder.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Authn + class EnterpriseUsersFinder < UsersFinder + extend ::Gitlab::Utils::Override + + private + + override :base_scope + def base_scope + group = params[:enterprise_group] + + raise(ArgumentError, 'Enterprise group is required for EnterpriseUsersFinder') unless group + raise(ArgumentError, 'Enterprise group must be a top-level group') unless group.root? + raise Gitlab::Access::AccessDeniedError unless user_owner_of_group?(group) + + group.enterprise_users.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/api/group_enterprise_users.rb b/ee/lib/api/group_enterprise_users.rb new file mode 100644 index 0000000000000000000000000000000000000000..00605ee2b8ce42abe3a2b98eefaa0a5190bc8b1c --- /dev/null +++ b/ee/lib/api/group_enterprise_users.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module API + class GroupEnterpriseUsers < ::API::Base + include PaginationParams + + feature_category :user_management + + before do + authenticate! + bad_request!('Must be a top-level group') unless user_group.root? + authorize! :owner_access, user_group + end + + params do + requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the group' + end + + resource :groups, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Get a list of enterprise 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.' + optional( + :two_factor, + type: String, + desc: 'Filter users by two-factor authentication (2FA). ' \ + 'Filter values are `enabled` or `disabled`. By default it returns all users.' + ) + + use :pagination + end + get ':id/enterprise_users' do + finder = ::Authn::EnterpriseUsersFinder.new( + current_user, + declared_params.merge(enterprise_group: user_group)) + + users = finder.execute.preload(:identities, :scim_identities) # rubocop: disable CodeReuse/ActiveRecord -- preload + + present paginate(users), with: ::API::Entities::UserPublic + end + end + end +end diff --git a/ee/lib/ee/api/api.rb b/ee/lib/ee/api/api.rb index 4bfd8d2cfcb1149e036e1f15c576e5a35e1b794f..cf233bc54afefda8b309e218150fc6e72b321cfd 100644 --- a/ee/lib/ee/api/api.rb +++ b/ee/lib/ee/api/api.rb @@ -69,6 +69,7 @@ module API mount ::API::GroupProtectedBranches mount ::API::DependencyListExports mount ::API::GroupServiceAccounts + mount ::API::GroupEnterpriseUsers mount ::API::Ai::Llm::GitCommand mount ::API::Ai::DuoWorkflows::Workflows mount ::API::Ai::DuoWorkflows::WorkflowsInternal diff --git a/ee/spec/finders/authn/enterprise_users_finder_spec.rb b/ee/spec/finders/authn/enterprise_users_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ed771bd19357f01dd2e7ef7a28f5981549e1c7b8 --- /dev/null +++ b/ee/spec/finders/authn/enterprise_users_finder_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Authn::EnterpriseUsersFinder, feature_category: :user_management do + describe '#execute' do + let_it_be(:enterprise_group) { create(:group) } + + let_it_be(:subgroup) { create(:group, parent: enterprise_group) } + + let_it_be(:developer_of_enterprise_group) { create(:user, developer_of: enterprise_group) } + let_it_be(:maintainer_of_enterprise_group) { create(:user, maintainer_of: enterprise_group) } + let_it_be(:owner_of_enterprise_group) { create(:user, owner_of: enterprise_group) } + + let_it_be(:non_enterprise_user) { create(:user) } + let_it_be(:enterprise_user_of_another_group) { create(:enterprise_user) } + + let_it_be(:enterprise_user_of_the_group) do + create(:enterprise_user, enterprise_group: enterprise_group) + end + + let_it_be(:blocked_enterprise_user_of_the_group) do + create(:enterprise_user, :blocked, enterprise_group: enterprise_group) + end + + let_it_be(:enterprise_user_and_member_of_the_group) do + create(:enterprise_user, enterprise_group: enterprise_group, developer_of: enterprise_group) + end + + let(:current_user) { owner_of_enterprise_group } + + let(:params) { { enterprise_group: enterprise_group } } + + subject(:finder) { described_class.new(current_user, params).execute } + + describe '#execute' do + context 'when enterprise_group parameter is not passed' do + let(:params) { {} } + + it 'raises error that enterprise group is required' do + expect { finder }.to raise_error(ArgumentError, 'Enterprise group is required for EnterpriseUsersFinder') + end + end + + context 'when enterprise_group parameter is not top-level group' do + let(:params) { { enterprise_group: subgroup } } + + it 'raises error that enterprise group must be a top-level group' do + expect { finder }.to raise_error(ArgumentError, 'Enterprise 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_enterprise_group } + + it 'raises Gitlab::Access::AccessDeniedError' do + expect { finder }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + it 'returns enterprise users of the group in descending order by id' do + users = finder + + expect(users).to eq( + [ + enterprise_user_of_the_group, + blocked_enterprise_user_of_the_group, + enterprise_user_and_member_of_the_group + ].sort_by(&:id).reverse + ) + end + + context 'for search parameter' do + context 'for search by name' do + let(:params) { { enterprise_group: enterprise_group, search: enterprise_user_of_the_group.name } } + + it 'returns enterprise users of the group according to the search parameter' do + users = finder + + expect(users).to eq( + [ + enterprise_user_of_the_group + ] + ) + end + end + + context 'for search by username' do + let(:params) { { enterprise_group: enterprise_group, search: blocked_enterprise_user_of_the_group.username } } + + it 'returns enterprise users of the group according to the search parameter' do + users = finder + + expect(users).to eq( + [ + blocked_enterprise_user_of_the_group + ] + ) + end + end + + context 'for search by public email' do + let_it_be(:enterprise_user_of_the_group_with_public_email) do + create(:enterprise_user, :public_email, enterprise_group: enterprise_group) + end + + let(:params) do + { enterprise_group: enterprise_group, search: enterprise_user_of_the_group_with_public_email.public_email } + end + + it 'returns enterprise users of the group according to the search parameter', :aggregate_failures do + expect(enterprise_user_of_the_group_with_public_email.public_email).to be_present + + users = finder + + expect(users).to eq( + [ + enterprise_user_of_the_group_with_public_email + ] + ) + end + end + + context 'for search by private email' do + let_it_be(:enterprise_user_of_the_group_without_public_email) do + create(:enterprise_user, enterprise_group: enterprise_group) + end + + let(:params) do + { enterprise_group: enterprise_group, search: enterprise_user_of_the_group_without_public_email.email } + end + + it 'returns enterprise users of the group according to the search parameter', :aggregate_failures do + expect(enterprise_user_of_the_group_without_public_email.public_email).not_to be_present + + users = finder + + expect(users).to eq( + [ + enterprise_user_of_the_group_without_public_email + ] + ) + end + end + end + end + end +end diff --git a/ee/spec/requests/api/group_enterprise_users_spec.rb b/ee/spec/requests/api/group_enterprise_users_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..833bb64573d2628f692f888d881bcb16a0cc2494 --- /dev/null +++ b/ee/spec/requests/api/group_enterprise_users_spec.rb @@ -0,0 +1,324 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::GroupEnterpriseUsers, :aggregate_failures, feature_category: :user_management do + let_it_be(:enterprise_group) { create(:group) } + let_it_be(:saml_provider) { create(:saml_provider, group: enterprise_group) } + + let_it_be(:subgroup) { create(:group, parent: enterprise_group) } + + let_it_be(:developer_of_enterprise_group) { create(:user, developer_of: enterprise_group) } + let_it_be(:maintainer_of_enterprise_group) { create(:user, maintainer_of: enterprise_group) } + let_it_be(:owner_of_enterprise_group) { create(:user, owner_of: enterprise_group) } + + let_it_be(:non_enterprise_user) { create(:user) } + let_it_be(:enterprise_user_of_another_group) { create(:enterprise_user) } + + let_it_be(:enterprise_user_of_the_group) do + create(:enterprise_user, :with_namespace, enterprise_group: enterprise_group).tap do |user| + create(:group_saml_identity, user: user, saml_provider: saml_provider) + create(:scim_identity, user: user, group: enterprise_group) + end + end + + let_it_be(:blocked_enterprise_user_of_the_group) do + create(:enterprise_user, :blocked, :with_namespace, enterprise_group: enterprise_group) + end + + let_it_be(:enterprise_user_and_member_of_the_group) do + create(:enterprise_user, :with_namespace, enterprise_group: enterprise_group, developer_of: enterprise_group) + end + + let(:current_user) { owner_of_enterprise_group } + let(:group_id) { enterprise_group.id } + let(:params) { {} } + + shared_examples 'authentication and authorization requirements' do + context 'when current_user is nil' do + let(:current_user) { nil } + + it 'returns 401 Unauthorized' do + subject + + 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 + subject + + 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 + subject + + 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_enterprise_group } + + it 'returns 403 Forbidden' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('403 Forbidden') + end + end + end + + describe 'GET /groups/:id/enterprise_users' do + subject(:get_group_enterprise_users) do + get api("/groups/#{group_id}/enterprise_users", current_user), params: params + end + + include_examples 'authentication and authorization requirements' + + it 'returns enterprise users of the group in descending order by id' do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + enterprise_user_of_the_group, + blocked_enterprise_user_of_the_group, + enterprise_user_and_member_of_the_group + ].sort_by(&:id).reverse.pluck(:id) + ) + end + + context 'for pagination parameters' do + let(:params) { { page: 1, per_page: 2 } } + + it 'returns enterprise users according to page and per_page parameters' do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + enterprise_user_of_the_group, + blocked_enterprise_user_of_the_group, + enterprise_user_and_member_of_the_group + ].sort_by(&:id).reverse.slice(0, 2).pluck(:id) + ) + end + end + + context 'for username parameter' do + let(:params) { { username: enterprise_user_of_the_group.username } } + + it 'returns single enterprise user with a specific username' do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(enterprise_user_of_the_group.id) + end + end + + context 'for search parameter' do + context 'for search by name' do + let(:params) { { search: enterprise_user_of_the_group.name } } + + it 'returns enterprise users of the group according to the search parameter' do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(enterprise_user_of_the_group.id) + end + end + + context 'for search by username' do + let(:params) { { search: blocked_enterprise_user_of_the_group.username } } + + it 'returns enterprise users of the group according to the search parameter' do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(blocked_enterprise_user_of_the_group.id) + end + end + + context 'for search by public email' do + let_it_be(:enterprise_user_of_the_group_with_public_email) do + create(:enterprise_user, :public_email, :with_namespace, enterprise_group: enterprise_group) + end + + let(:params) do + { search: enterprise_user_of_the_group_with_public_email.public_email } + end + + it 'returns enterprise users of the group according to the search parameter' do + expect(enterprise_user_of_the_group_with_public_email.public_email).to be_present + + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(enterprise_user_of_the_group_with_public_email.id) + end + end + + context 'for search by private email' do + let_it_be(:enterprise_user_of_the_group_without_public_email) do + create(:enterprise_user, :with_namespace, enterprise_group: enterprise_group) + end + + let(:params) do + { search: enterprise_user_of_the_group_without_public_email.email } + end + + it 'returns enterprise users of the group according to the search parameter' do + expect(enterprise_user_of_the_group_without_public_email.public_email).not_to be_present + + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(enterprise_user_of_the_group_without_public_email.id) + end + end + end + + context 'for ative parameter' do + let(:params) { { active: true } } + + it 'returns only active enterprise users' do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + enterprise_user_of_the_group, + enterprise_user_and_member_of_the_group + ].sort_by(&:id).reverse.pluck(:id) + ) + end + end + + context 'for blocked parameter' do + let(:params) { { blocked: true } } + + it 'returns only blocked enterprise users' do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + blocked_enterprise_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(:enterprise_user_of_the_group_created_12_days_ago) do + create(:enterprise_user, :with_namespace, enterprise_group: enterprise_group).tap do |user| + user.update_column(:created_at, 12.days.ago) + end + end + + let_it_be(:enterprise_user_of_the_group_created_8_days_ago) do + create(:enterprise_user, :with_namespace, enterprise_group: enterprise_group).tap do |user| + user.update_column(:created_at, 8.days.ago) + end + end + + it 'returns only enterprise users created after the specified time', :freeze_time do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + enterprise_user_of_the_group, + blocked_enterprise_user_of_the_group, + enterprise_user_and_member_of_the_group, + enterprise_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(:enterprise_user_of_the_group_created_12_days_ago) do + create(:enterprise_user, :with_namespace, enterprise_group: enterprise_group).tap do |user| + user.update_column(:created_at, 12.days.ago) + end + end + + let_it_be(:enterprise_user_of_the_group_created_8_days_ago) do + create(:enterprise_user, :with_namespace, enterprise_group: enterprise_group).tap do |user| + user.update_column(:created_at, 8.days.ago) + end + end + + it 'returns only enterprise users created before the specified time', :freeze_time do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + enterprise_user_of_the_group_created_12_days_ago + ].sort_by(&:id).reverse.pluck(:id) + ) + end + end + + context 'for two_factor parameter' do + let_it_be(:enterprise_user_of_the_group_with_two_factor_enabled) do + create(:enterprise_user, :two_factor, :with_namespace, enterprise_group: enterprise_group) + end + + context 'when enabled value' do + let(:params) { { two_factor: 'enabled' } } + + it 'returns only enterprise users with two-factor enabled' do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + enterprise_user_of_the_group_with_two_factor_enabled + ].sort_by(&:id).reverse.pluck(:id) + ) + end + end + + context 'when disabled value' do + let(:params) { { two_factor: 'disabled' } } + + it 'returns only enterprise users with two-factor disabled' do + get_group_enterprise_users + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq( + [ + enterprise_user_of_the_group, + blocked_enterprise_user_of_the_group, + enterprise_user_and_member_of_the_group + ].sort_by(&:id).reverse.pluck(:id) + ) + end + end + end + end +end